chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from autogen import ConversableAgent
|
||||
|
||||
from semantic_kernel.agents.autogen.autogen_conversable_agent import (
|
||||
AutoGenConversableAgent,
|
||||
AutoGenConversableAgentThread,
|
||||
)
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentInvokeException
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_conversable_agent():
|
||||
agent = MagicMock(spec=ConversableAgent)
|
||||
agent.name = "MockName"
|
||||
agent.description = "MockDescription"
|
||||
agent.system_message = "MockSystemMessage"
|
||||
return agent
|
||||
|
||||
|
||||
async def test_autogen_conversable_agent_initialization(mock_conversable_agent):
|
||||
agent = AutoGenConversableAgent(mock_conversable_agent, id="mock_id")
|
||||
assert agent.name == "MockName"
|
||||
assert agent.description == "MockDescription"
|
||||
assert agent.instructions == "MockSystemMessage"
|
||||
assert agent.conversable_agent == mock_conversable_agent
|
||||
|
||||
|
||||
async def test_autogen_conversable_agent_get_response(mock_conversable_agent):
|
||||
mock_conversable_agent.a_generate_reply = AsyncMock(return_value="Mocked assistant response")
|
||||
agent = AutoGenConversableAgent(mock_conversable_agent)
|
||||
thread: AutoGenConversableAgentThread = None
|
||||
|
||||
response = await agent.get_response("Hello", thread=thread)
|
||||
assert response.message.role == AuthorRole.ASSISTANT
|
||||
assert response.message.content == "Mocked assistant response"
|
||||
assert response.thread is not None
|
||||
|
||||
|
||||
async def test_autogen_conversable_agent_get_response_exception(mock_conversable_agent):
|
||||
mock_conversable_agent.a_generate_reply = AsyncMock(return_value=None)
|
||||
agent = AutoGenConversableAgent(mock_conversable_agent)
|
||||
|
||||
with pytest.raises(AgentInvokeException):
|
||||
await agent.get_response("Hello")
|
||||
|
||||
|
||||
async def test_autogen_conversable_agent_invoke_with_recipient(mock_conversable_agent):
|
||||
mock_conversable_agent.a_initiate_chat = AsyncMock()
|
||||
mock_conversable_agent.a_initiate_chat.return_value = MagicMock(
|
||||
chat_history=[
|
||||
{"role": "user", "content": "Hello from user!"},
|
||||
{"role": "assistant", "content": "Hello from assistant!"},
|
||||
]
|
||||
)
|
||||
agent = AutoGenConversableAgent(mock_conversable_agent)
|
||||
recipient_agent = MagicMock(spec=AutoGenConversableAgent)
|
||||
recipient_agent.conversable_agent = MagicMock(spec=ConversableAgent)
|
||||
|
||||
messages = []
|
||||
async for response in agent.invoke(recipient=recipient_agent, messages="Test message", arg1="arg1"):
|
||||
messages.append(response)
|
||||
|
||||
mock_conversable_agent.a_initiate_chat.assert_awaited_once()
|
||||
assert len(messages) == 2
|
||||
assert messages[0].message.role == AuthorRole.USER
|
||||
assert messages[0].message.content == "Hello from user!"
|
||||
assert messages[1].message.role == AuthorRole.ASSISTANT
|
||||
assert messages[1].message.content == "Hello from assistant!"
|
||||
|
||||
|
||||
async def test_autogen_conversable_agent_invoke_without_recipient_string_reply(mock_conversable_agent):
|
||||
mock_conversable_agent.a_generate_reply = AsyncMock(return_value="Mocked assistant response")
|
||||
agent = AutoGenConversableAgent(mock_conversable_agent)
|
||||
|
||||
responses = []
|
||||
async for response in agent.invoke(messages="Hello"):
|
||||
responses.append(response)
|
||||
|
||||
mock_conversable_agent.a_generate_reply.assert_awaited_once()
|
||||
assert len(responses) == 1
|
||||
assert responses[0].message.role == AuthorRole.ASSISTANT
|
||||
assert responses[0].message.content == "Mocked assistant response"
|
||||
|
||||
|
||||
async def test_autogen_conversable_agent_invoke_without_recipient_dict_reply(mock_conversable_agent):
|
||||
mock_conversable_agent.a_generate_reply = AsyncMock(
|
||||
return_value={
|
||||
"content": "Mocked assistant response",
|
||||
"role": "assistant",
|
||||
"name": "AssistantName",
|
||||
}
|
||||
)
|
||||
agent = AutoGenConversableAgent(mock_conversable_agent)
|
||||
|
||||
responses = []
|
||||
async for response in agent.invoke(messages="Hello"):
|
||||
responses.append(response)
|
||||
|
||||
mock_conversable_agent.a_generate_reply.assert_awaited_once()
|
||||
assert len(responses) == 1
|
||||
assert responses[0].message.role == AuthorRole.ASSISTANT
|
||||
assert responses[0].message.content == "Mocked assistant response"
|
||||
assert responses[0].message.name == "AssistantName"
|
||||
|
||||
|
||||
async def test_autogen_conversable_agent_invoke_without_recipient_unexpected_type(mock_conversable_agent):
|
||||
mock_conversable_agent.a_generate_reply = AsyncMock(return_value=12345)
|
||||
agent = AutoGenConversableAgent(mock_conversable_agent)
|
||||
|
||||
with pytest.raises(AgentInvokeException):
|
||||
async for _ in agent.invoke(messages="Hello"):
|
||||
pass
|
||||
|
||||
|
||||
async def test_autogen_conversable_agent_invoke_with_invalid_recipient_type(mock_conversable_agent):
|
||||
mock_conversable_agent.a_generate_reply = AsyncMock(return_value=12345)
|
||||
agent = AutoGenConversableAgent(mock_conversable_agent)
|
||||
|
||||
recipient = MagicMock()
|
||||
|
||||
with pytest.raises(AgentInvokeException):
|
||||
async for _ in agent.invoke(recipient=recipient, messages="Hello"):
|
||||
pass
|
||||
@@ -0,0 +1,28 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from azure.ai.agents.models import Agent as AzureAIAgentModel
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ai_project_client() -> AsyncMock:
|
||||
client = AsyncMock(spec=AIProjectClient)
|
||||
|
||||
agents_mock = MagicMock()
|
||||
client.agents = agents_mock
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ai_agent_definition() -> AsyncMock:
|
||||
definition = AsyncMock(spec=AzureAIAgentModel)
|
||||
definition.id = "agent123"
|
||||
definition.name = "agentName"
|
||||
definition.description = "desc"
|
||||
definition.instructions = "test agent"
|
||||
|
||||
return definition
|
||||
@@ -0,0 +1,464 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from azure.ai.agents.models import (
|
||||
MessageDelta,
|
||||
MessageDeltaChunk,
|
||||
MessageDeltaImageFileContent,
|
||||
MessageDeltaImageFileContentObject,
|
||||
MessageDeltaTextContent,
|
||||
MessageDeltaTextContentObject,
|
||||
MessageDeltaTextFileCitationAnnotation,
|
||||
MessageDeltaTextFileCitationAnnotationObject,
|
||||
MessageDeltaTextFilePathAnnotation,
|
||||
MessageDeltaTextFilePathAnnotationObject,
|
||||
MessageDeltaTextUrlCitationAnnotation,
|
||||
MessageDeltaTextUrlCitationDetails,
|
||||
MessageImageFileContent,
|
||||
MessageImageFileDetails,
|
||||
MessageTextContent,
|
||||
MessageTextDetails,
|
||||
MessageTextFileCitationAnnotation,
|
||||
MessageTextFileCitationDetails,
|
||||
MessageTextFilePathAnnotation,
|
||||
MessageTextFilePathDetails,
|
||||
MessageTextUrlCitationAnnotation,
|
||||
MessageTextUrlCitationDetails,
|
||||
RequiredFunctionToolCall,
|
||||
RunStep,
|
||||
RunStepBingCustomSearchToolCall,
|
||||
RunStepBingGroundingToolCall,
|
||||
RunStepDeltaFunction,
|
||||
RunStepDeltaFunctionToolCall,
|
||||
RunStepDeltaToolCallObject,
|
||||
RunStepFunctionToolCall,
|
||||
RunStepFunctionToolCallDetails,
|
||||
ThreadMessage,
|
||||
)
|
||||
|
||||
from semantic_kernel.agents.azure_ai.agent_content_generation import (
|
||||
THREAD_MESSAGE_ID,
|
||||
generate_annotation_content,
|
||||
generate_bing_grounding_content,
|
||||
generate_code_interpreter_content,
|
||||
generate_function_call_content,
|
||||
generate_function_result_content,
|
||||
generate_message_content,
|
||||
generate_streaming_annotation_content,
|
||||
generate_streaming_code_interpreter_content,
|
||||
generate_streaming_function_content,
|
||||
generate_streaming_message_content,
|
||||
get_function_call_contents,
|
||||
get_message_contents,
|
||||
)
|
||||
from semantic_kernel.contents.annotation_content import AnnotationContent
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.file_reference_content import FileReferenceContent
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
from semantic_kernel.contents.function_result_content import FunctionResultContent
|
||||
from semantic_kernel.contents.image_content import ImageContent
|
||||
from semantic_kernel.contents.streaming_annotation_content import StreamingAnnotationContent
|
||||
from semantic_kernel.contents.streaming_file_reference_content import StreamingFileReferenceContent
|
||||
from semantic_kernel.contents.streaming_text_content import StreamingTextContent
|
||||
from semantic_kernel.contents.text_content import TextContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
|
||||
|
||||
def test_get_message_contents_all_types():
|
||||
chat_msg = ChatMessageContent(role=AuthorRole.USER, content="")
|
||||
chat_msg.items.append(TextContent(text="hello world"))
|
||||
chat_msg.items.append(ImageContent(uri="http://example.com/image.png"))
|
||||
chat_msg.items.append(FileReferenceContent(file_id="file123"))
|
||||
chat_msg.items.append(FunctionResultContent(id="func1", result={"a": 1}))
|
||||
results = get_message_contents(chat_msg)
|
||||
assert len(results) == 4
|
||||
assert results[0]["type"] == "text"
|
||||
assert results[1]["type"] == "image_url"
|
||||
assert results[2]["type"] == "image_file"
|
||||
assert results[3]["type"] == "text"
|
||||
|
||||
|
||||
def test_generate_message_content_text_and_image():
|
||||
thread_msg = ThreadMessage(
|
||||
content=[],
|
||||
role="user",
|
||||
)
|
||||
|
||||
image = MessageImageFileContent(image_file=MessageImageFileDetails(file_id="test_file_id"))
|
||||
|
||||
text = MessageTextContent(
|
||||
text=MessageTextDetails(
|
||||
value="some text",
|
||||
annotations=[
|
||||
MessageTextFileCitationAnnotation(
|
||||
text="text",
|
||||
file_citation=MessageTextFileCitationDetails(file_id="file_id", quote="some quote"),
|
||||
start_index=0,
|
||||
end_index=9,
|
||||
),
|
||||
MessageTextFilePathAnnotation(
|
||||
text="text again",
|
||||
file_path=MessageTextFilePathDetails(file_id="file_id_2"),
|
||||
start_index=1,
|
||||
end_index=10,
|
||||
),
|
||||
MessageTextUrlCitationAnnotation(
|
||||
text="text",
|
||||
url_citation=MessageTextUrlCitationDetails(title="some title", url="http://example.com"),
|
||||
start_index=1,
|
||||
end_index=10,
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
thread_msg.content = [image, text]
|
||||
step = RunStep(id="step_id", run_id="run_id", thread_id="thread_id", agent_id="agent_id")
|
||||
out = generate_message_content("assistant", thread_msg, step)
|
||||
assert len(out.items) == 5
|
||||
assert isinstance(out.items[0], FileReferenceContent)
|
||||
assert isinstance(out.items[1], TextContent)
|
||||
assert isinstance(out.items[2], AnnotationContent)
|
||||
assert isinstance(out.items[3], AnnotationContent)
|
||||
assert isinstance(out.items[4], AnnotationContent)
|
||||
|
||||
assert out.items[0].file_id == "test_file_id"
|
||||
|
||||
assert out.items[1].text == "some text"
|
||||
|
||||
assert out.items[2].file_id == "file_id"
|
||||
assert out.items[2].quote == "text"
|
||||
assert out.items[2].start_index == 0
|
||||
assert out.items[2].end_index == 9
|
||||
assert out.items[2].citation_type == "file_citation"
|
||||
|
||||
assert out.items[3].file_id == "file_id_2"
|
||||
assert out.items[3].quote == "text again"
|
||||
assert out.items[3].start_index == 1
|
||||
assert out.items[3].end_index == 10
|
||||
assert out.items[3].citation_type == "file_path"
|
||||
|
||||
assert out.items[4].url == "http://example.com"
|
||||
assert out.items[4].quote == "text"
|
||||
assert out.items[4].start_index == 1
|
||||
assert out.items[4].end_index == 10
|
||||
assert out.items[4].title == "some title"
|
||||
assert out.items[4].citation_type == "url_citation"
|
||||
|
||||
assert out.metadata["step_id"] == "step_id"
|
||||
assert out.role == AuthorRole.USER
|
||||
|
||||
|
||||
def test_generate_annotation_content():
|
||||
message_text_file_path_ann = MessageTextFilePathAnnotation(
|
||||
text="some text",
|
||||
file_path=MessageTextFilePathDetails(file_id="file123"),
|
||||
start_index=0,
|
||||
end_index=9,
|
||||
)
|
||||
|
||||
message_text_file_citation_ann = MessageTextFileCitationAnnotation(
|
||||
text="some text",
|
||||
file_citation=MessageTextFileCitationDetails(file_id="file123"),
|
||||
start_index=0,
|
||||
end_index=9,
|
||||
)
|
||||
|
||||
for fake_ann in [message_text_file_path_ann, message_text_file_citation_ann]:
|
||||
out = generate_annotation_content(fake_ann)
|
||||
assert out.file_id == "file123"
|
||||
assert out.quote == "some text"
|
||||
assert out.start_index == 0
|
||||
assert out.end_index == 9
|
||||
|
||||
|
||||
def test_generate_streaming_message_content_text_annotations():
|
||||
message_delta_image_file_content = MessageDeltaImageFileContent(
|
||||
index=0,
|
||||
image_file=MessageDeltaImageFileContentObject(file_id="image_file"),
|
||||
)
|
||||
|
||||
MessageDeltaTextFileCitationAnnotation, MessageDeltaTextFilePathAnnotation
|
||||
|
||||
message_delta_text_content = MessageDeltaTextContent(
|
||||
index=0,
|
||||
text=MessageDeltaTextContentObject(
|
||||
value="some text",
|
||||
annotations=[
|
||||
MessageDeltaTextFileCitationAnnotation(
|
||||
index=0,
|
||||
file_citation=MessageDeltaTextFileCitationAnnotationObject(file_id="file123", quote="some text"),
|
||||
start_index=0,
|
||||
end_index=9,
|
||||
text="some text",
|
||||
),
|
||||
MessageDeltaTextFilePathAnnotation(
|
||||
index=0,
|
||||
file_path=MessageDeltaTextFilePathAnnotationObject(file_id="file123"),
|
||||
start_index=1,
|
||||
end_index=10,
|
||||
text="some text",
|
||||
),
|
||||
MessageDeltaTextUrlCitationAnnotation(
|
||||
index=0,
|
||||
url_citation=MessageDeltaTextUrlCitationDetails(
|
||||
title="some title",
|
||||
url="http://example.com",
|
||||
),
|
||||
start_index=2,
|
||||
end_index=11,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
delta = MessageDeltaChunk(
|
||||
id="chunk123",
|
||||
delta=MessageDelta(role="user", content=[message_delta_image_file_content, message_delta_text_content]),
|
||||
)
|
||||
|
||||
out = generate_streaming_message_content("assistant", delta)
|
||||
assert out is not None
|
||||
assert out.content == "some text"
|
||||
assert len(out.items) == 5
|
||||
assert out.items[0].file_id == "image_file"
|
||||
assert isinstance(out.items[0], StreamingFileReferenceContent)
|
||||
assert isinstance(out.items[1], StreamingTextContent)
|
||||
assert isinstance(out.items[2], StreamingAnnotationContent)
|
||||
|
||||
assert out.items[2].file_id == "file123"
|
||||
assert out.items[2].quote == "some text"
|
||||
assert out.items[2].start_index == 0
|
||||
assert out.items[2].end_index == 9
|
||||
assert out.items[2].citation_type == "file_citation"
|
||||
|
||||
assert isinstance(out.items[3], StreamingAnnotationContent)
|
||||
assert out.items[3].file_id == "file123"
|
||||
assert out.items[3].quote == "some text"
|
||||
assert out.items[3].start_index == 1
|
||||
assert out.items[3].end_index == 10
|
||||
assert out.items[3].citation_type == "file_path"
|
||||
|
||||
assert isinstance(out.items[4], StreamingAnnotationContent)
|
||||
assert out.items[4].url == "http://example.com"
|
||||
assert out.items[4].title == "some title"
|
||||
assert out.items[4].start_index == 2
|
||||
assert out.items[4].end_index == 11
|
||||
assert out.items[4].citation_type == "url_citation"
|
||||
|
||||
|
||||
def test_generate_annotation_content_url_annotation_without_indices():
|
||||
ann = MessageTextUrlCitationAnnotation(
|
||||
text="url text",
|
||||
url_citation=MessageTextUrlCitationDetails(title="", url="http://ex.com"),
|
||||
start_index=None,
|
||||
end_index=None,
|
||||
)
|
||||
out = generate_annotation_content(ann)
|
||||
assert out.file_id is None
|
||||
assert out.url == "http://ex.com"
|
||||
assert out.title == "" # preserve empty title
|
||||
assert out.quote == "url text"
|
||||
assert out.start_index is None
|
||||
assert out.end_index is None
|
||||
assert out.citation_type == "url_citation"
|
||||
|
||||
|
||||
def test_generate_streaming_annotation_content_url_quote_none_and_missing_indices():
|
||||
ann = MessageDeltaTextUrlCitationAnnotation(
|
||||
index=0,
|
||||
url_citation=MessageDeltaTextUrlCitationDetails(title="", url="http://ex.com"),
|
||||
start_index=None,
|
||||
end_index=None,
|
||||
)
|
||||
out = generate_streaming_annotation_content(ann)
|
||||
assert out.file_id is None
|
||||
assert out.url == "http://ex.com"
|
||||
assert out.title == ""
|
||||
assert out.quote is None # no .text on URL annotation
|
||||
assert out.start_index is None
|
||||
assert out.end_index is None
|
||||
assert out.citation_type == "url_citation"
|
||||
|
||||
|
||||
def test_generate_streaming_message_content_text_only_no_annotations():
|
||||
delta = MessageDeltaChunk(
|
||||
id="c1",
|
||||
delta=MessageDelta(
|
||||
role="assistant",
|
||||
content=[
|
||||
MessageDeltaTextContent(
|
||||
index=0,
|
||||
text=MessageDeltaTextContentObject(value="just text", annotations=[]),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
out = generate_streaming_message_content("assistant", delta, thread_msg_id="thread_1")
|
||||
assert out.content == "just text"
|
||||
assert len(out.items) == 1
|
||||
assert isinstance(out.items[0], StreamingTextContent)
|
||||
assert out.items[0].text == "just text"
|
||||
assert out.metadata.get(THREAD_MESSAGE_ID) == "thread_1"
|
||||
|
||||
|
||||
def test_generate_annotation_content_empty_title_and_url_only():
|
||||
ann = MessageTextUrlCitationAnnotation(
|
||||
text=None,
|
||||
url_citation=MessageTextUrlCitationDetails(title=None, url="http://empty.com"),
|
||||
start_index=5,
|
||||
end_index=10,
|
||||
)
|
||||
out = generate_annotation_content(ann)
|
||||
assert out.quote is None # allow None text
|
||||
assert out.url == "http://empty.com"
|
||||
assert out.title is None # allow None title
|
||||
assert out.start_index == 5
|
||||
assert out.end_index == 10
|
||||
|
||||
|
||||
def test_generate_streaming_annotation_content_file_and_citation_have_text():
|
||||
file_ann = MessageDeltaTextFileCitationAnnotation(
|
||||
index=0,
|
||||
file_citation=MessageDeltaTextFileCitationAnnotationObject(file_id="f1", quote="q1"),
|
||||
start_index=2,
|
||||
end_index=4,
|
||||
text="q1",
|
||||
)
|
||||
out = generate_streaming_annotation_content(file_ann)
|
||||
assert out.file_id == "f1"
|
||||
assert out.quote == "q1"
|
||||
assert out.citation_type == "file_citation"
|
||||
assert out.start_index == 2
|
||||
assert out.end_index == 4
|
||||
|
||||
|
||||
def test_generate_streaming_function_content_with_function():
|
||||
step_details = RunStepDeltaToolCallObject(
|
||||
tool_calls=[
|
||||
RunStepDeltaFunctionToolCall(
|
||||
index=0, id="tool123", function=RunStepDeltaFunction(name="some_func", arguments={"arg": "val"})
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
out = generate_streaming_function_content("my_agent", step_details)
|
||||
assert out is not None
|
||||
assert len(out.items) == 1
|
||||
assert isinstance(out.items[0], FunctionCallContent)
|
||||
assert out.items[0].function_name == "some_func"
|
||||
assert out.items[0].arguments == "{'arg': 'val'}"
|
||||
|
||||
|
||||
def test_get_function_call_contents_no_action():
|
||||
run = type("ThreadRunFake", (), {"required_action": None})()
|
||||
fc = get_function_call_contents(run, {})
|
||||
assert fc == []
|
||||
|
||||
|
||||
def test_get_function_call_contents_submit_tool_outputs():
|
||||
fake_function = MagicMock()
|
||||
fake_function.name = "test_function"
|
||||
fake_function.arguments = {"arg": "val"}
|
||||
|
||||
fake_tool_call = MagicMock(spec=RequiredFunctionToolCall)
|
||||
fake_tool_call.id = "tool_id"
|
||||
fake_tool_call.function = fake_function
|
||||
|
||||
run = MagicMock()
|
||||
run.required_action.submit_tool_outputs.tool_calls = [fake_tool_call]
|
||||
|
||||
function_steps = {}
|
||||
fc = get_function_call_contents(run, function_steps)
|
||||
|
||||
assert len(fc) == 1
|
||||
assert fc[0].id == "tool_id"
|
||||
assert fc[0].name == "test_function"
|
||||
assert fc[0].arguments == {"arg": "val"}
|
||||
|
||||
|
||||
def test_generate_function_call_content():
|
||||
fcc = FunctionCallContent(id="id123", name="func_name", arguments={"x": 1})
|
||||
msg = generate_function_call_content("my_agent", [fcc])
|
||||
assert len(msg.items) == 1
|
||||
assert msg.role == AuthorRole.ASSISTANT
|
||||
|
||||
|
||||
def test_generate_function_result_content():
|
||||
step = FunctionCallContent(id="123", name="func_name", arguments={"k": "v"})
|
||||
|
||||
tool_call = RunStepFunctionToolCall(
|
||||
id="123",
|
||||
function=RunStepFunctionToolCallDetails({
|
||||
"name": "func_name",
|
||||
"arguments": '{"k": "v"}',
|
||||
"output": "result_data",
|
||||
}),
|
||||
)
|
||||
msg = generate_function_result_content("my_agent", step, tool_call)
|
||||
assert len(msg.items) == 1
|
||||
assert msg.items[0].result == "result_data"
|
||||
assert msg.role == AuthorRole.TOOL
|
||||
|
||||
|
||||
def test_generate_code_interpreter_content():
|
||||
msg = generate_code_interpreter_content("my_agent", "some_code()")
|
||||
assert msg.content == "some_code()"
|
||||
assert msg.metadata["code"] is True
|
||||
|
||||
|
||||
def test_generate_streaming_code_interpreter_content_no_calls():
|
||||
step_details = type("Details", (), {"tool_calls": None})
|
||||
assert generate_streaming_code_interpreter_content("my_agent", step_details) is None
|
||||
|
||||
|
||||
def test_generate_bing_grounding_content():
|
||||
"""Test generate_bing_grounding_content with RunStepBingGroundingToolCall."""
|
||||
bing_grounding_tool_call = RunStepBingGroundingToolCall(
|
||||
id="call_gvgTmSL4hgdxWP4O7LLnwMlt",
|
||||
bing_grounding={
|
||||
"requesturl": "https://api.bing.microsoft.com/v7.0/search?q=search",
|
||||
"response_metadata": "{'market': 'en-US', 'num_docs_retrieved': 5, 'num_docs_actually_used': 5}",
|
||||
},
|
||||
)
|
||||
|
||||
msg = generate_bing_grounding_content("my_agent", bing_grounding_tool_call)
|
||||
|
||||
assert len(msg.items) == 1
|
||||
assert msg.role == AuthorRole.ASSISTANT
|
||||
assert isinstance(msg.items[0], FunctionCallContent)
|
||||
assert msg.items[0].id == "call_gvgTmSL4hgdxWP4O7LLnwMlt"
|
||||
assert msg.items[0].name == "bing_grounding"
|
||||
assert msg.items[0].function_name == "bing_grounding"
|
||||
assert msg.items[0].arguments["requesturl"] == "https://api.bing.microsoft.com/v7.0/search?q=search"
|
||||
assert msg.items[0].arguments["response_metadata"] == (
|
||||
"{'market': 'en-US', 'num_docs_retrieved': 5, 'num_docs_actually_used': 5}"
|
||||
)
|
||||
|
||||
|
||||
def test_generate_bing_custom_search_content():
|
||||
"""Test generate_bing_grounding_content with RunStepBingCustomSearchToolCall."""
|
||||
bing_custom_search_tool_call = RunStepBingCustomSearchToolCall(
|
||||
id="call_abc123def456ghi",
|
||||
bing_custom_search={
|
||||
"query": "semantic kernel python",
|
||||
"custom_config_id": "config_123",
|
||||
"search_results": "{'num_results': 10, 'top_result': 'Microsoft Semantic Kernel'}",
|
||||
},
|
||||
)
|
||||
|
||||
msg = generate_bing_grounding_content("my_agent", bing_custom_search_tool_call)
|
||||
|
||||
assert len(msg.items) == 1
|
||||
assert msg.role == AuthorRole.ASSISTANT
|
||||
assert isinstance(msg.items[0], FunctionCallContent)
|
||||
assert msg.items[0].id == "call_abc123def456ghi"
|
||||
assert msg.items[0].name == "bing_custom_search"
|
||||
assert msg.items[0].function_name == "bing_custom_search"
|
||||
assert msg.items[0].arguments["query"] == "semantic kernel python"
|
||||
assert msg.items[0].arguments["custom_config_id"] == "config_123"
|
||||
assert msg.items[0].arguments["search_results"] == (
|
||||
"{'num_results': 10, 'top_result': 'Microsoft Semantic Kernel'}"
|
||||
)
|
||||
@@ -0,0 +1,662 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from azure.ai.agents.models import (
|
||||
MessageTextContent,
|
||||
MessageTextDetails,
|
||||
RequiredFunctionToolCall,
|
||||
RequiredFunctionToolCallDetails,
|
||||
RunStep,
|
||||
RunStepCodeInterpreterToolCall,
|
||||
RunStepCodeInterpreterToolCallDetails,
|
||||
RunStepFunctionToolCall,
|
||||
RunStepFunctionToolCallDetails,
|
||||
RunStepMessageCreationDetails,
|
||||
RunStepMessageCreationReference,
|
||||
RunStepToolCallDetails,
|
||||
SubmitToolOutputsAction,
|
||||
SubmitToolOutputsDetails,
|
||||
ThreadMessage,
|
||||
ThreadRun,
|
||||
)
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from pytest import fixture
|
||||
|
||||
from semantic_kernel.agents.azure_ai.agent_thread_actions import AgentThreadActions
|
||||
from semantic_kernel.agents.azure_ai.azure_ai_agent import AzureAIAgent
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.contents import FunctionCallContent, FunctionResultContent, TextContent
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentInvokeException
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
from semantic_kernel.functions.kernel_plugin import KernelPlugin
|
||||
from semantic_kernel.kernel import Kernel
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_client():
|
||||
mock_thread = AsyncMock()
|
||||
mock_thread.id = "thread123"
|
||||
|
||||
mock_threads = MagicMock()
|
||||
mock_threads.create = AsyncMock(return_value=mock_thread)
|
||||
|
||||
mock_message = AsyncMock()
|
||||
mock_message.id = "message456"
|
||||
|
||||
mock_messages = MagicMock()
|
||||
mock_messages.create = AsyncMock(return_value="someMessage")
|
||||
|
||||
mock_agents = MagicMock()
|
||||
mock_agents.threads = mock_threads
|
||||
mock_agents.messages = mock_messages
|
||||
|
||||
mock_client = AsyncMock(spec=AIProjectClient)
|
||||
mock_client.agents = mock_agents
|
||||
|
||||
return mock_client
|
||||
|
||||
|
||||
async def test_agent_thread_actions_create_thread(mock_client):
|
||||
thread_id = await AgentThreadActions.create_thread(mock_client)
|
||||
assert thread_id == "thread123"
|
||||
|
||||
|
||||
async def test_agent_thread_actions_create_message(mock_client):
|
||||
msg = ChatMessageContent(role=AuthorRole.USER, content="some content")
|
||||
out = await AgentThreadActions.create_message(mock_client, "threadXYZ", msg)
|
||||
assert out == "someMessage"
|
||||
|
||||
|
||||
async def test_agent_thread_actions_create_message_no_content():
|
||||
class FakeAgentClient:
|
||||
create_message = AsyncMock(return_value="should_not_be_called")
|
||||
|
||||
class FakeClient:
|
||||
agents = FakeAgentClient()
|
||||
|
||||
message = ChatMessageContent(role=AuthorRole.USER, content=" ")
|
||||
out = await AgentThreadActions.create_message(FakeClient(), "threadXYZ", message)
|
||||
assert out is None
|
||||
assert FakeAgentClient.create_message.await_count == 0
|
||||
|
||||
|
||||
async def test_agent_thread_actions_invoke(ai_project_client: AIProjectClient, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
|
||||
# Properly construct nested mocks without re-spec'ing from a mock
|
||||
mock_thread_run = ThreadRun(
|
||||
id="run123",
|
||||
thread_id="thread123",
|
||||
status="running",
|
||||
instructions="test agent",
|
||||
created_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
model="model",
|
||||
)
|
||||
|
||||
agent.client.agents.runs = MagicMock()
|
||||
agent.client.agents.runs.create = AsyncMock(return_value=mock_thread_run)
|
||||
agent.client.agents.runs.get = AsyncMock(return_value=mock_thread_run)
|
||||
|
||||
async def mock_poll_run_status(*args, **kwargs):
|
||||
yield RunStep(
|
||||
type="message_creation",
|
||||
id="msg123",
|
||||
thread_id="thread123",
|
||||
run_id="run123",
|
||||
created_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
completed_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
status="completed",
|
||||
agent_id="agent123",
|
||||
step_details=RunStepMessageCreationDetails(
|
||||
message_creation=RunStepMessageCreationReference(
|
||||
message_id="msg123",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
agent.client.agents.run_steps = MagicMock()
|
||||
agent.client.agents.run_steps.list = mock_poll_run_status
|
||||
|
||||
mock_message = ThreadMessage(
|
||||
id="msg123",
|
||||
thread_id="thread123",
|
||||
run_id="run123",
|
||||
created_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
completed_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
status="completed",
|
||||
agent_id="agent123",
|
||||
role="assistant",
|
||||
content=[MessageTextContent(text=MessageTextDetails(value="some message", annotations=[]))],
|
||||
)
|
||||
|
||||
agent.client.agents.messages = MagicMock()
|
||||
agent.client.agents.messages.get = AsyncMock(return_value=mock_message)
|
||||
|
||||
async for is_visible, message in AgentThreadActions.invoke(
|
||||
agent=agent, thread_id="thread123", kernel=AsyncMock(spec=Kernel)
|
||||
):
|
||||
assert str(message.content) == "some message"
|
||||
break
|
||||
|
||||
|
||||
async def test_agent_thread_actions_invoke_with_requires_action(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
agent.client.agents = MagicMock()
|
||||
|
||||
mock_thread_run = ThreadRun(
|
||||
id="run123",
|
||||
thread_id="thread123",
|
||||
status="running",
|
||||
instructions="test agent",
|
||||
created_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
model="model",
|
||||
)
|
||||
|
||||
agent.client.agents = MagicMock()
|
||||
|
||||
agent.client.agents.runs = MagicMock()
|
||||
agent.client.agents.runs.create = AsyncMock(return_value=mock_thread_run)
|
||||
agent.client.agents.runs.get = AsyncMock(return_value=mock_thread_run)
|
||||
agent.client.agents.runs.submit_tool_outputs = AsyncMock()
|
||||
|
||||
poll_count = 0
|
||||
|
||||
async def mock_poll_run_status(*args, **kwargs):
|
||||
nonlocal poll_count
|
||||
if poll_count == 0:
|
||||
mock_thread_run.status = "requires_action"
|
||||
mock_thread_run.required_action = SubmitToolOutputsAction(
|
||||
submit_tool_outputs=SubmitToolOutputsDetails(
|
||||
tool_calls=[
|
||||
RequiredFunctionToolCall(
|
||||
id="tool_call_id",
|
||||
function=RequiredFunctionToolCallDetails(
|
||||
name="mock_function_call", arguments={"arg": "value"}
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
else:
|
||||
mock_thread_run.status = "completed"
|
||||
poll_count += 1
|
||||
return mock_thread_run
|
||||
|
||||
def mock_get_function_call_contents(run: ThreadRun, function_steps: dict):
|
||||
function_call_content = FunctionCallContent(
|
||||
name="mock_function_call",
|
||||
arguments={"arg": "value"},
|
||||
id="tool_call_id",
|
||||
)
|
||||
function_steps[function_call_content.id] = function_call_content
|
||||
return [function_call_content]
|
||||
|
||||
mock_run_step_tool_calls = RunStep(
|
||||
type="tool_calls",
|
||||
id="tool_step123",
|
||||
thread_id="thread123",
|
||||
run_id="run123",
|
||||
created_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
completed_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
status="completed",
|
||||
agent_id="agent123",
|
||||
step_details=RunStepToolCallDetails(
|
||||
tool_calls=[
|
||||
# 1. This will yield FunctionResultContent
|
||||
RunStepFunctionToolCall(
|
||||
id="tool_call_id",
|
||||
function=RunStepFunctionToolCallDetails({
|
||||
"name": "mock_function_call",
|
||||
"arguments": '{"arg": "value"}',
|
||||
"output": "some output",
|
||||
}),
|
||||
),
|
||||
# 2. This will yield TextContent
|
||||
RunStepCodeInterpreterToolCall(
|
||||
id="tool_call_id",
|
||||
code_interpreter=RunStepCodeInterpreterToolCallDetails(
|
||||
input="some code",
|
||||
),
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
mock_run_step_message_creation = RunStep(
|
||||
type="message_creation",
|
||||
id="msg_step123",
|
||||
thread_id="thread123",
|
||||
run_id="run123",
|
||||
created_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
completed_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
status="completed",
|
||||
agent_id="agent123",
|
||||
step_details=RunStepMessageCreationDetails(
|
||||
message_creation=RunStepMessageCreationReference(message_id="msg123")
|
||||
),
|
||||
)
|
||||
|
||||
mock_run_steps = [mock_run_step_tool_calls, mock_run_step_message_creation]
|
||||
|
||||
async def mock_list_run_steps(*args, **kwargs):
|
||||
for step in mock_run_steps:
|
||||
yield step
|
||||
|
||||
agent.client.agents.run_steps = MagicMock()
|
||||
agent.client.agents.run_steps.list = mock_list_run_steps
|
||||
|
||||
mock_message = ThreadMessage(
|
||||
id="msg123",
|
||||
thread_id="thread123",
|
||||
run_id="run123",
|
||||
created_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
completed_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
status="completed",
|
||||
agent_id="agent123",
|
||||
role="assistant",
|
||||
content=[MessageTextContent(text=MessageTextDetails(value="some message", annotations=[]))],
|
||||
)
|
||||
agent.client.agents.runs.get = AsyncMock(return_value=mock_message)
|
||||
|
||||
agent.client.agents.runs.submit_tool_outputs = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(AgentThreadActions, "_poll_run_status", side_effect=mock_poll_run_status),
|
||||
patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.get_function_call_contents",
|
||||
side_effect=mock_get_function_call_contents,
|
||||
),
|
||||
patch.object(AgentThreadActions, "_invoke_function_calls", return_value=[None]),
|
||||
):
|
||||
messages = []
|
||||
async for is_visible, content in AgentThreadActions.invoke(
|
||||
agent=agent,
|
||||
thread_id="thread123",
|
||||
kernel=AsyncMock(spec=Kernel),
|
||||
):
|
||||
messages.append((is_visible, content))
|
||||
|
||||
assert len(messages) == 3, "There should be three yields in total."
|
||||
|
||||
assert isinstance(messages[0][1].items[0], FunctionCallContent)
|
||||
assert isinstance(messages[1][1].items[0], FunctionResultContent)
|
||||
assert isinstance(messages[2][1].items[0], TextContent)
|
||||
|
||||
agent.client.agents.runs.submit_tool_outputs.assert_awaited_once()
|
||||
|
||||
|
||||
class MockEvent:
|
||||
def __init__(self, event, data):
|
||||
self.event = event
|
||||
self.data = data
|
||||
|
||||
def __iter__(self):
|
||||
return iter((self.event, self.data, None))
|
||||
|
||||
|
||||
class MockRunData:
|
||||
def __init__(self, id, status, content: str | None = None):
|
||||
self.id = id
|
||||
self.status = status
|
||||
self.content = content
|
||||
|
||||
|
||||
class MockAsyncIterable:
|
||||
def __init__(self, items):
|
||||
self.items = items.copy()
|
||||
|
||||
def __aiter__(self):
|
||||
self._iter = iter(self.items)
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return next(self._iter)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration
|
||||
|
||||
|
||||
class MockStream:
|
||||
def __init__(self, events):
|
||||
self.events = events
|
||||
|
||||
async def __aenter__(self):
|
||||
return MockAsyncIterable(self.events)
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
|
||||
async def test_agent_thread_actions_invoke_stream(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
agent.client.agents = AsyncMock()
|
||||
|
||||
events = [
|
||||
MockEvent("thread.run.created", MockRunData(id="run_1", status="queued")),
|
||||
MockEvent("thread.message.created", MockRunData(id="msg_1", status="created", content="Hello")),
|
||||
MockEvent("thread.run.in_progress", MockRunData(id="run_1", status="in_progress")),
|
||||
MockEvent("thread.run.completed", MockRunData(id="run_1", status="completed")),
|
||||
]
|
||||
|
||||
main_run_stream = MockStream(events)
|
||||
agent.client.agents.create_stream.return_value = main_run_stream
|
||||
|
||||
with (
|
||||
patch.object(AgentThreadActions, "_invoke_function_calls", return_value=None),
|
||||
patch.object(AgentThreadActions, "_format_tool_outputs", return_value=[{"type": "mock_tool_output"}]),
|
||||
):
|
||||
collected_messages = []
|
||||
async for content in AgentThreadActions.invoke_stream(
|
||||
agent=agent,
|
||||
thread_id="thread123",
|
||||
kernel=AsyncMock(spec=Kernel),
|
||||
):
|
||||
collected_messages.append(content)
|
||||
assert isinstance(content, ChatMessageContent)
|
||||
assert content.metadata.get("message_id") == "msg_1"
|
||||
|
||||
|
||||
# region Security tests for tools override and function_choice_behavior
|
||||
|
||||
|
||||
async def test_validate_function_choice_behavior_rejects_required():
|
||||
"""Required FCB is not supported for agent invocations."""
|
||||
with pytest.raises(AgentInvokeException, match="not supported"):
|
||||
AgentThreadActions._validate_function_choice_behavior(FunctionChoiceBehavior.Required())
|
||||
|
||||
|
||||
async def test_validate_function_choice_behavior_accepts_auto():
|
||||
"""Auto FCB should be accepted without error."""
|
||||
AgentThreadActions._validate_function_choice_behavior(FunctionChoiceBehavior.Auto())
|
||||
|
||||
|
||||
async def test_validate_function_choice_behavior_rejects_none_invoke():
|
||||
"""NoneInvoke FCB is not supported for agent invocations."""
|
||||
with pytest.raises(AgentInvokeException, match="not supported"):
|
||||
AgentThreadActions._validate_function_choice_behavior(FunctionChoiceBehavior.NoneInvoke())
|
||||
|
||||
|
||||
async def test_validate_function_choice_behavior_accepts_none():
|
||||
"""None (no FCB) should be accepted."""
|
||||
AgentThreadActions._validate_function_choice_behavior(None)
|
||||
|
||||
|
||||
async def test_validate_function_choice_behavior_rejects_auto_invoke_false():
|
||||
"""Auto with auto_invoke=False is not supported for agent invocations."""
|
||||
with pytest.raises(AgentInvokeException, match="auto_invoke"):
|
||||
AgentThreadActions._validate_function_choice_behavior(FunctionChoiceBehavior.Auto(auto_invoke=False))
|
||||
|
||||
|
||||
async def test_validate_function_choice_behavior_rejects_empty_filters():
|
||||
"""Empty filters dict should be rejected."""
|
||||
fcb = FunctionChoiceBehavior.Auto()
|
||||
fcb.filters = {}
|
||||
with pytest.raises(AgentInvokeException, match="must not be empty"):
|
||||
AgentThreadActions._validate_function_choice_behavior(fcb)
|
||||
|
||||
|
||||
async def test_validate_function_choice_behavior_rejects_unknown_filter_keys():
|
||||
"""Unknown filter keys should be rejected."""
|
||||
fcb = FunctionChoiceBehavior.Auto()
|
||||
# Bypass Pydantic validation to simulate a mistyped key reaching the validator
|
||||
object.__setattr__(fcb, "filters", {"include_functions": ["foo"]})
|
||||
with pytest.raises(AgentInvokeException, match="Unknown filter key"):
|
||||
AgentThreadActions._validate_function_choice_behavior(fcb)
|
||||
|
||||
|
||||
async def test_validate_function_choice_behavior_accepts_valid_filters():
|
||||
"""Valid filter keys should be accepted."""
|
||||
AgentThreadActions._validate_function_choice_behavior(
|
||||
FunctionChoiceBehavior.Auto(filters={"included_functions": ["plugin-func"]})
|
||||
)
|
||||
|
||||
|
||||
async def test_get_tools_with_tools_override(ai_project_client, ai_agent_definition):
|
||||
"""When tools_override is provided, it should replace agent.definition.tools."""
|
||||
from azure.ai.agents.models import CodeInterpreterToolDefinition
|
||||
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
kernel = MagicMock(spec=Kernel)
|
||||
kernel.get_full_list_of_function_metadata.return_value = []
|
||||
|
||||
override_tool = CodeInterpreterToolDefinition()
|
||||
tools = AgentThreadActions._get_tools(agent=agent, kernel=kernel, tools_override=[override_tool])
|
||||
# Should contain the override tool, not agent.definition.tools
|
||||
assert any(
|
||||
(isinstance(t, CodeInterpreterToolDefinition) or (isinstance(t, dict) and t.get("type") == "code_interpreter"))
|
||||
for t in tools
|
||||
)
|
||||
|
||||
|
||||
async def test_get_tools_with_fcb_filters(ai_project_client, ai_agent_definition):
|
||||
"""When function_choice_behavior has filters, only matching functions should be included."""
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
kernel = MagicMock(spec=Kernel)
|
||||
|
||||
# Simulate filtered metadata
|
||||
mock_metadata = MagicMock()
|
||||
mock_metadata.fully_qualified_name = "Plugin-AllowedFunc"
|
||||
mock_metadata.name = "AllowedFunc"
|
||||
mock_metadata.plugin_name = "Plugin"
|
||||
mock_metadata.description = "An allowed function"
|
||||
mock_metadata.parameters = []
|
||||
mock_metadata.is_prompt = False
|
||||
mock_metadata.return_parameter = MagicMock()
|
||||
mock_metadata.return_parameter.description = ""
|
||||
mock_metadata.return_parameter.type_ = "str"
|
||||
mock_metadata.additional_properties = {}
|
||||
|
||||
kernel.get_list_of_function_metadata.return_value = [mock_metadata]
|
||||
kernel.get_full_list_of_function_metadata.return_value = []
|
||||
|
||||
fcb = FunctionChoiceBehavior.Auto(filters={"included_functions": ["Plugin-AllowedFunc"]})
|
||||
AgentThreadActions._get_tools(agent=agent, kernel=kernel, function_choice_behavior=fcb)
|
||||
# Should have called get_list_of_function_metadata with the filters
|
||||
kernel.get_list_of_function_metadata.assert_called_once_with(fcb.filters)
|
||||
|
||||
|
||||
async def test_get_tools_with_fcb_disable_kernel_functions(ai_project_client, ai_agent_definition):
|
||||
"""When enable_kernel_functions=False, no kernel functions should be included."""
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
kernel = MagicMock(spec=Kernel)
|
||||
|
||||
fcb = FunctionChoiceBehavior.Auto(enable_kernel_functions=False)
|
||||
AgentThreadActions._get_tools(agent=agent, kernel=kernel, function_choice_behavior=fcb)
|
||||
# Full list is called for validation, but filtered list should not be called
|
||||
kernel.get_full_list_of_function_metadata.assert_called_once()
|
||||
kernel.get_list_of_function_metadata.assert_not_called()
|
||||
|
||||
|
||||
async def test_invoke_function_calls_passes_function_behavior():
|
||||
"""_invoke_function_calls should pass function_behavior to kernel.invoke_function_call."""
|
||||
mock_kernel = AsyncMock(spec=Kernel)
|
||||
mock_kernel.invoke_function_call.return_value = None
|
||||
|
||||
fcc = FunctionCallContent(name="Plugin-Func", arguments={}, id="call1")
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
|
||||
chat_history = ChatHistory()
|
||||
fcb = FunctionChoiceBehavior.Auto(filters={"included_functions": ["Plugin-Func"]})
|
||||
|
||||
await AgentThreadActions._invoke_function_calls(
|
||||
kernel=mock_kernel,
|
||||
fccs=[fcc],
|
||||
chat_history=chat_history,
|
||||
arguments=KernelArguments(),
|
||||
function_choice_behavior=fcb,
|
||||
)
|
||||
|
||||
mock_kernel.invoke_function_call.assert_awaited_once()
|
||||
call_kwargs = mock_kernel.invoke_function_call.call_args
|
||||
assert call_kwargs.kwargs.get("function_behavior") is fcb
|
||||
|
||||
|
||||
async def test_invoke_function_calls_passes_disabled_kernel_functions():
|
||||
"""_invoke_function_calls should pass enable_kernel_functions=False FCB to kernel."""
|
||||
mock_kernel = AsyncMock(spec=Kernel)
|
||||
mock_kernel.invoke_function_call.return_value = None
|
||||
|
||||
fcc = FunctionCallContent(name="Plugin-Func", arguments={}, id="call1")
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
|
||||
chat_history = ChatHistory()
|
||||
fcb = FunctionChoiceBehavior.Auto(enable_kernel_functions=False)
|
||||
|
||||
await AgentThreadActions._invoke_function_calls(
|
||||
kernel=mock_kernel,
|
||||
fccs=[fcc],
|
||||
chat_history=chat_history,
|
||||
arguments=KernelArguments(),
|
||||
function_choice_behavior=fcb,
|
||||
)
|
||||
|
||||
mock_kernel.invoke_function_call.assert_awaited_once()
|
||||
call_kwargs = mock_kernel.invoke_function_call.call_args
|
||||
passed_behavior = call_kwargs.kwargs.get("function_behavior")
|
||||
assert passed_behavior is fcb
|
||||
assert not passed_behavior.enable_kernel_functions
|
||||
|
||||
|
||||
async def test_invoke_function_calls_blocks_disallowed_function():
|
||||
"""A real Kernel should block a function call not in the FCB allowlist.
|
||||
|
||||
This verifies that the enforcement in kernel.invoke_function_call actually
|
||||
rejects a disallowed function name when filters are provided, rather than
|
||||
only asserting that the kwarg is forwarded.
|
||||
"""
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod
|
||||
|
||||
@kernel_function
|
||||
def allowed_func() -> str:
|
||||
return "allowed"
|
||||
|
||||
@kernel_function
|
||||
def disallowed_func() -> str:
|
||||
return "disallowed"
|
||||
|
||||
kernel = Kernel()
|
||||
kernel.add_plugin(
|
||||
KernelPlugin(
|
||||
name="Plugin",
|
||||
functions=[
|
||||
KernelFunctionFromMethod(method=allowed_func, plugin_name="Plugin"),
|
||||
KernelFunctionFromMethod(method=disallowed_func, plugin_name="Plugin"),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
fcb = FunctionChoiceBehavior.Auto(filters={"included_functions": ["Plugin-allowed_func"]})
|
||||
|
||||
# Call a function NOT in the allowlist
|
||||
fcc = FunctionCallContent(
|
||||
name="Plugin-disallowed_func",
|
||||
plugin_name="Plugin",
|
||||
function_name="disallowed_func",
|
||||
arguments={},
|
||||
id="call1",
|
||||
)
|
||||
chat_history = ChatHistory()
|
||||
|
||||
result = await kernel.invoke_function_call(
|
||||
function_call=fcc,
|
||||
chat_history=chat_history,
|
||||
function_behavior=fcb,
|
||||
)
|
||||
# invoke_function_call catches the FunctionExecutionException and returns None,
|
||||
# adding an error message to chat_history instead of raising.
|
||||
assert result is None
|
||||
assert len(chat_history.messages) == 1
|
||||
result_item = chat_history.messages[0].items[0]
|
||||
assert "not part of the provided tools" in str(result_item.result)
|
||||
|
||||
|
||||
async def test_invoke_function_calls_allows_permitted_function():
|
||||
"""A real Kernel should allow a function call that IS in the FCB allowlist."""
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod
|
||||
|
||||
@kernel_function
|
||||
def allowed_func() -> str:
|
||||
return "ok"
|
||||
|
||||
@kernel_function
|
||||
def other_func() -> str:
|
||||
return "other"
|
||||
|
||||
kernel = Kernel()
|
||||
kernel.add_plugin(
|
||||
KernelPlugin(
|
||||
name="Plugin",
|
||||
functions=[
|
||||
KernelFunctionFromMethod(method=allowed_func, plugin_name="Plugin"),
|
||||
KernelFunctionFromMethod(method=other_func, plugin_name="Plugin"),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
fcb = FunctionChoiceBehavior.Auto(filters={"included_functions": ["Plugin-allowed_func"]})
|
||||
|
||||
fcc = FunctionCallContent(
|
||||
name="Plugin-allowed_func",
|
||||
plugin_name="Plugin",
|
||||
function_name="allowed_func",
|
||||
arguments={},
|
||||
id="call1",
|
||||
)
|
||||
chat_history = ChatHistory()
|
||||
|
||||
await kernel.invoke_function_call(
|
||||
function_call=fcc,
|
||||
chat_history=chat_history,
|
||||
function_behavior=fcb,
|
||||
)
|
||||
# Should succeed — the function result should be in chat_history
|
||||
assert len(chat_history.messages) == 1
|
||||
result_item = chat_history.messages[0].items[0]
|
||||
assert "ok" in str(result_item.result)
|
||||
|
||||
|
||||
async def test_invoke_raises_for_non_auto_fcb(ai_project_client, ai_agent_definition):
|
||||
"""Calling AgentThreadActions.invoke() with a non-Auto FCB should raise before any API call."""
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
agent.client.agents = AsyncMock()
|
||||
|
||||
with pytest.raises(AgentInvokeException, match="not supported"):
|
||||
async for _ in AgentThreadActions.invoke(
|
||||
agent=agent,
|
||||
thread_id="thread123",
|
||||
kernel=Kernel(),
|
||||
function_choice_behavior=FunctionChoiceBehavior.Required(),
|
||||
):
|
||||
pass
|
||||
|
||||
# No API calls should have been made
|
||||
agent.client.agents.runs.create.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_invoke_stream_raises_for_non_auto_fcb(ai_project_client, ai_agent_definition):
|
||||
"""Calling AgentThreadActions.invoke_stream() with a non-Auto FCB should raise before any API call."""
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
agent.client.agents = AsyncMock()
|
||||
|
||||
with pytest.raises(AgentInvokeException, match="not supported"):
|
||||
async for _ in AgentThreadActions.invoke_stream(
|
||||
agent=agent,
|
||||
thread_id="thread123",
|
||||
kernel=Kernel(),
|
||||
function_choice_behavior=FunctionChoiceBehavior.NoneInvoke(),
|
||||
):
|
||||
pass
|
||||
|
||||
# No API calls should have been made
|
||||
agent.client.agents.create_stream.assert_not_called()
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,478 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from semantic_kernel.agents.agent import AgentResponseItem
|
||||
from semantic_kernel.agents.azure_ai.azure_ai_agent import AzureAIAgent, AzureAIAgentThread
|
||||
from semantic_kernel.agents.channels.agent_channel import AgentChannel
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
from semantic_kernel.contents.function_result_content import FunctionResultContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentInitializationException, AgentInvokeException
|
||||
|
||||
|
||||
async def test_azure_ai_agent_init(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
assert agent.id == "agent123"
|
||||
assert agent.name == "agentName"
|
||||
assert agent.description == "desc"
|
||||
|
||||
|
||||
async def test_azure_ai_agent_init_with_plugins_via_constructor(
|
||||
ai_project_client, ai_agent_definition, custom_plugin_class
|
||||
):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition, plugins=[custom_plugin_class()])
|
||||
assert agent.id == "agent123"
|
||||
assert agent.name == "agentName"
|
||||
assert agent.description == "desc"
|
||||
assert agent.kernel.plugins is not None
|
||||
assert len(agent.kernel.plugins) == 1
|
||||
|
||||
|
||||
async def test_azure_ai_agent_get_response(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
response = await agent.get_response(messages="message", thread=thread)
|
||||
assert response.message.role == AuthorRole.ASSISTANT
|
||||
assert response.message.content == "content"
|
||||
assert response.thread is not None
|
||||
|
||||
|
||||
async def test_azure_ai_agent_get_response_exception(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield False, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
),
|
||||
pytest.raises(AgentInvokeException),
|
||||
):
|
||||
await agent.get_response(messages="message", thread=thread)
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
results = []
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for item in agent.invoke(messages="message", thread=thread):
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_yields_visible_assistant_message(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
results = []
|
||||
|
||||
assistant_msg = ChatMessageContent(role=AuthorRole.ASSISTANT, content="assistant says hi")
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield True, assistant_msg
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for item in agent.invoke(messages="message", thread=thread):
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].message is assistant_msg
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_emits_tool_message_via_callback_only(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
|
||||
callback_results = []
|
||||
|
||||
async def handle_callback(msg: ChatMessageContent) -> None:
|
||||
callback_results.append(msg)
|
||||
|
||||
tool_msg = ChatMessageContent(role=AuthorRole.ASSISTANT, content="tool call")
|
||||
tool_msg.items = [FunctionCallContent(name="tool", arguments="{}")]
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield False, tool_msg
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for _ in agent.invoke(messages="message", thread=thread, on_intermediate_message=handle_callback):
|
||||
pass
|
||||
|
||||
assert callback_results == [tool_msg]
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_suppresses_tool_message_without_callback(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
|
||||
tool_msg = ChatMessageContent(role=AuthorRole.ASSISTANT, content="tool call")
|
||||
tool_msg.items = [FunctionCallContent(name="tool", arguments="{}")]
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield False, tool_msg # Not visible, no callback
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
results = [item async for item in agent.invoke(messages="message", thread=thread)]
|
||||
|
||||
assert results == [] # Tool message should be suppressed
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_stream(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
results = []
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for item in agent.invoke_stream(messages="message", thread=thread):
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_stream_with_on_new_message_callback(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
thread.id = "test_thread_id"
|
||||
results = []
|
||||
|
||||
final_chat_history = ChatHistory()
|
||||
|
||||
async def handle_stream_completion(message: ChatMessageContent) -> None:
|
||||
final_chat_history.add_message(message)
|
||||
|
||||
# Fake collected messages
|
||||
fake_message = StreamingChatMessageContent(role=AuthorRole.ASSISTANT, content="fake content", choice_index=0)
|
||||
|
||||
async def fake_invoke(*args, output_messages=None, **kwargs):
|
||||
if output_messages is not None:
|
||||
output_messages.append(fake_message)
|
||||
yield fake_message
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for item in agent.invoke_stream(
|
||||
messages="message", thread=thread, on_intermediate_message=handle_stream_completion
|
||||
):
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].message.content == "fake content"
|
||||
assert len(final_chat_history.messages) == 1
|
||||
assert final_chat_history.messages[0].content == "fake content"
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_stream_tool_message_only_goes_to_callback(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
thread.id = "test_thread_id"
|
||||
|
||||
received_callback_messages = []
|
||||
|
||||
async def async_append(msg: ChatMessageContent):
|
||||
received_callback_messages.append(msg)
|
||||
|
||||
tool_msg = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT, content="tool call", items=[FunctionCallContent(name="ToolA", arguments="{}")]
|
||||
)
|
||||
|
||||
streamed_msg = StreamingChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT, content="assistant streaming...", choice_index=0
|
||||
)
|
||||
|
||||
async def fake_invoke_stream(*args, output_messages=None, **kwargs):
|
||||
if output_messages is not None:
|
||||
output_messages.append(tool_msg)
|
||||
yield streamed_msg
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke_stream,
|
||||
):
|
||||
results = []
|
||||
async for item in agent.invoke_stream(messages="message", thread=thread, on_intermediate_message=async_append):
|
||||
results.append(item)
|
||||
|
||||
assert results == [AgentResponseItem(message=streamed_msg, thread=thread)]
|
||||
|
||||
assert received_callback_messages == [tool_msg]
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_stream_tool_message_suppressed_without_callback(
|
||||
ai_project_client, ai_agent_definition
|
||||
):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
thread.id = "test_thread_id"
|
||||
|
||||
tool_msg = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
content="tool result",
|
||||
items=[FunctionResultContent(id="test-id", name="ToolA", result="result")],
|
||||
)
|
||||
|
||||
streamed_msg = StreamingChatMessageContent(role=AuthorRole.ASSISTANT, content="assistant says hi", choice_index=0)
|
||||
|
||||
async def fake_invoke_stream(*args, output_messages=None, **kwargs):
|
||||
if output_messages is not None:
|
||||
output_messages.append(tool_msg)
|
||||
yield streamed_msg
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke_stream,
|
||||
):
|
||||
results = []
|
||||
async for item in agent.invoke_stream(messages="message", thread=thread):
|
||||
results.append(item)
|
||||
|
||||
# Only assistant-visible content should be yielded
|
||||
assert len(results) == 1
|
||||
assert results[0].message == streamed_msg
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_stream_mixed_messages(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
thread.id = "test_thread_id"
|
||||
|
||||
callback_results = []
|
||||
|
||||
async def async_append(msg: ChatMessageContent):
|
||||
callback_results.append(msg)
|
||||
|
||||
tool_msg = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT, content="tool call", items=[FunctionCallContent(name="tool", arguments="{}")]
|
||||
)
|
||||
|
||||
text_msg = StreamingChatMessageContent(role=AuthorRole.ASSISTANT, content="streamed text", choice_index=0)
|
||||
|
||||
async def fake_invoke_stream(*args, output_messages: list = None, **kwargs):
|
||||
if output_messages is not None:
|
||||
output_messages.append(tool_msg)
|
||||
yield text_msg
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke_stream,
|
||||
):
|
||||
results = []
|
||||
async for item in agent.invoke_stream(messages="message", thread=thread, on_intermediate_message=async_append):
|
||||
results.append(item)
|
||||
|
||||
assert callback_results == [tool_msg]
|
||||
assert results == [AgentResponseItem(message=text_msg, thread=thread)]
|
||||
|
||||
|
||||
def test_azure_ai_agent_get_channel_keys(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
keys = list(agent.get_channel_keys())
|
||||
assert len(keys) >= 2
|
||||
|
||||
|
||||
async def test_azure_ai_agent_create_channel(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.create_thread",
|
||||
side_effect="t",
|
||||
),
|
||||
patch(
|
||||
"semantic_kernel.agents.azure_ai.azure_ai_agent.AzureAIAgentThread.create",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch(
|
||||
"semantic_kernel.agents.azure_ai.azure_ai_agent.AzureAIAgentThread.id",
|
||||
new_callable=PropertyMock,
|
||||
) as mock_id,
|
||||
):
|
||||
mock_id.return_value = "mock-thread-id"
|
||||
|
||||
ch = await agent.create_channel()
|
||||
|
||||
assert isinstance(ch, AgentChannel)
|
||||
assert ch.thread_id == "mock-thread-id"
|
||||
|
||||
|
||||
def test_create_client_with_explicit_endpoint():
|
||||
credential = MagicMock(spec=AsyncTokenCredential)
|
||||
|
||||
with patch("semantic_kernel.agents.azure_ai.azure_ai_agent.AIProjectClient") as mock_client_cls:
|
||||
mock_client = MagicMock(spec=AIProjectClient)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = AzureAIAgent.create_client(
|
||||
credential=credential,
|
||||
endpoint="https://my-endpoint",
|
||||
extra_arg="extra_value",
|
||||
)
|
||||
|
||||
mock_client_cls.assert_called_once()
|
||||
_, kwargs = mock_client_cls.call_args
|
||||
|
||||
assert kwargs["credential"] is credential
|
||||
assert kwargs["endpoint"] == "https://my-endpoint"
|
||||
assert kwargs["extra_arg"] == "extra_value"
|
||||
assert result is mock_client
|
||||
|
||||
|
||||
def test_create_client_uses_settings_when_endpoint_none():
|
||||
credential = MagicMock(spec=AsyncTokenCredential)
|
||||
|
||||
with (
|
||||
patch("semantic_kernel.agents.azure_ai.azure_ai_agent.AzureAIAgentSettings") as mock_settings_cls,
|
||||
patch("semantic_kernel.agents.azure_ai.azure_ai_agent.AIProjectClient") as mock_client_cls,
|
||||
):
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.endpoint = "https://configured-endpoint"
|
||||
mock_settings_cls.return_value = mock_settings
|
||||
|
||||
mock_client = MagicMock(spec=AIProjectClient)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = AzureAIAgent.create_client(credential=credential)
|
||||
|
||||
mock_client_cls.assert_called_once()
|
||||
_, kwargs = mock_client_cls.call_args
|
||||
|
||||
assert kwargs["endpoint"] == "https://configured-endpoint"
|
||||
assert result is mock_client
|
||||
|
||||
|
||||
def test_create_client_raises_if_no_endpoint():
|
||||
credential = MagicMock(spec=AsyncTokenCredential)
|
||||
|
||||
with patch("semantic_kernel.agents.azure_ai.azure_ai_agent.AzureAIAgentSettings") as mock_settings_cls:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.endpoint = None
|
||||
mock_settings_cls.return_value = mock_settings
|
||||
|
||||
try:
|
||||
AzureAIAgent.create_client(credential=credential)
|
||||
except AgentInitializationException as e:
|
||||
assert "Azure AI endpoint" in str(e)
|
||||
else:
|
||||
assert False, "Expected AgentInitializationException to be raised"
|
||||
|
||||
|
||||
async def test_azure_ai_agent_get_response_passes_function_choice_behavior(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
fcb = FunctionChoiceBehavior.Auto()
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
await agent.get_response(messages="message", thread=thread, function_choice_behavior=fcb)
|
||||
|
||||
assert captured_kwargs.get("function_choice_behavior") is fcb
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_passes_function_choice_behavior(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
fcb = FunctionChoiceBehavior.Auto()
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for _ in agent.invoke(messages="message", thread=thread, function_choice_behavior=fcb):
|
||||
pass
|
||||
|
||||
assert captured_kwargs.get("function_choice_behavior") is fcb
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_stream_passes_function_choice_behavior(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
fcb = FunctionChoiceBehavior.Auto()
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
yield ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for _ in agent.invoke_stream(messages="message", thread=thread, function_choice_behavior=fcb):
|
||||
pass
|
||||
|
||||
assert captured_kwargs.get("function_choice_behavior") is fcb
|
||||
|
||||
|
||||
async def test_azure_ai_agent_get_response_no_fcb_passes_none(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
await agent.get_response(messages="message", thread=thread)
|
||||
|
||||
assert captured_kwargs.get("function_choice_behavior") is None
|
||||
@@ -0,0 +1,34 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
from pydantic import Field, SecretStr, ValidationError
|
||||
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseSettings
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
class AzureAIAgentSettings(KernelBaseSettings):
|
||||
"""Slightly modified to ensure invalid data raises ValidationError."""
|
||||
|
||||
env_prefix = "AZURE_AI_AGENT_"
|
||||
model_deployment_name: str = Field(min_length=1)
|
||||
project_connection_string: SecretStr = Field(..., min_length=1)
|
||||
|
||||
|
||||
def test_azure_ai_agent_settings_valid():
|
||||
settings = AzureAIAgentSettings(
|
||||
model_deployment_name="test_model",
|
||||
project_connection_string="secret_value",
|
||||
)
|
||||
assert settings.model_deployment_name == "test_model"
|
||||
assert settings.project_connection_string.get_secret_value() == "secret_value"
|
||||
|
||||
|
||||
def test_azure_ai_agent_settings_invalid():
|
||||
with pytest.raises(ValidationError):
|
||||
# Should fail due to min_length=1 constraints
|
||||
AzureAIAgentSettings(
|
||||
model_deployment_name="", # empty => invalid
|
||||
project_connection_string="",
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from azure.ai.agents.models import MessageAttachment, MessageRole
|
||||
|
||||
from semantic_kernel.agents.azure_ai.azure_ai_agent_utils import AzureAIAgentUtils
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.file_reference_content import FileReferenceContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
|
||||
|
||||
def test_azure_ai_agent_utils_get_thread_messages_none():
|
||||
msgs = AzureAIAgentUtils.get_thread_messages([])
|
||||
assert msgs is None
|
||||
|
||||
|
||||
def test_azure_ai_agent_utils_get_thread_messages():
|
||||
msg1 = ChatMessageContent(role=AuthorRole.USER, content="Hello!")
|
||||
msg1.items.append(FileReferenceContent(file_id="file123"))
|
||||
results = AzureAIAgentUtils.get_thread_messages([msg1])
|
||||
assert len(results) == 1
|
||||
assert results[0].content == "Hello!"
|
||||
assert results[0].role == MessageRole.USER
|
||||
assert len(results[0].attachments) == 1
|
||||
assert isinstance(results[0].attachments[0], MessageAttachment)
|
||||
|
||||
|
||||
def test_azure_ai_agent_utils_get_attachments_empty():
|
||||
msg1 = ChatMessageContent(role=AuthorRole.USER, content="No file items")
|
||||
atts = AzureAIAgentUtils.get_attachments(msg1)
|
||||
assert atts == []
|
||||
|
||||
|
||||
def test_azure_ai_agent_utils_get_attachments_file():
|
||||
msg1 = ChatMessageContent(role=AuthorRole.USER, content="One file item")
|
||||
msg1.items.append(FileReferenceContent(file_id="file123"))
|
||||
atts = AzureAIAgentUtils.get_attachments(msg1)
|
||||
assert len(atts) == 1
|
||||
assert atts[0].file_id == "file123"
|
||||
|
||||
|
||||
def test_azure_ai_agent_utils_get_metadata():
|
||||
msg1 = ChatMessageContent(role=AuthorRole.USER, content="has meta", metadata={"k": 123})
|
||||
meta = AzureAIAgentUtils.get_metadata(msg1)
|
||||
assert meta["k"] == "123"
|
||||
|
||||
|
||||
def test_azure_ai_agent_utils_get_tool_definition():
|
||||
gen = AzureAIAgentUtils._get_tool_definition(["file_search", "code_interpreter", "non_existent"])
|
||||
# file_search & code_interpreter exist, non_existent yields nothing
|
||||
tools_list = list(gen)
|
||||
assert len(tools_list) == 2
|
||||
@@ -0,0 +1,88 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
|
||||
from semantic_kernel.agents.azure_ai.azure_ai_agent import AzureAIAgent
|
||||
from semantic_kernel.agents.azure_ai.azure_ai_channel import AzureAIChannel
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentChatException
|
||||
|
||||
|
||||
async def test_azure_ai_channel_invoke_invalid_agent():
|
||||
channel = AzureAIChannel(AsyncMock(spec=AIProjectClient), "thread123")
|
||||
with pytest.raises(AgentChatException):
|
||||
async for _ in channel.invoke(object()):
|
||||
pass
|
||||
|
||||
|
||||
async def test_azure_ai_channel_invoke_valid_agent(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
channel = AzureAIChannel(ai_project_client, "thread123")
|
||||
results = []
|
||||
async for is_visible, msg in channel.invoke(agent):
|
||||
results.append((is_visible, msg))
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
async def test_azure_ai_channel_invoke_stream_valid_agent(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
channel = AzureAIChannel(ai_project_client, "thread123")
|
||||
results = []
|
||||
async for is_visible, msg in channel.invoke_stream(agent, messages=[]):
|
||||
results.append((is_visible, msg))
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
async def test_azure_ai_channel_get_history():
|
||||
# We need to return an async iterable, so let's do an AsyncMock returning an _async_gen
|
||||
class FakeAgentClient:
|
||||
delete_thread = AsyncMock()
|
||||
# We'll patch get_messages directly below
|
||||
|
||||
class FakeClient:
|
||||
agents = FakeAgentClient()
|
||||
|
||||
channel = AzureAIChannel(FakeClient(), "threadXYZ")
|
||||
|
||||
async def fake_get_messages(client, thread_id):
|
||||
# Must produce an async iterable
|
||||
yield ChatMessageContent(role=AuthorRole.ASSISTANT, content="Previous msg")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.get_messages",
|
||||
new=fake_get_messages, # direct replacement with a coroutine
|
||||
):
|
||||
results = []
|
||||
async for item in channel.get_history():
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].content == "Previous msg"
|
||||
|
||||
|
||||
# Helper for returning an async generator
|
||||
async def _async_gen(items):
|
||||
for i in items:
|
||||
yield i
|
||||
@@ -0,0 +1,187 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.bedrock.models.bedrock_agent_event_type import BedrockAgentEventType
|
||||
from semantic_kernel.agents.bedrock.models.bedrock_agent_model import BedrockAgentModel
|
||||
from semantic_kernel.agents.bedrock.models.bedrock_agent_status import BedrockAgentStatus
|
||||
from semantic_kernel.kernel import Kernel
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def bedrock_agent_unit_test_env(monkeypatch, exclude_list, override_env_param_dict):
|
||||
"""Fixture to set environment variables for Amazon Bedrock Agent unit tests."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"BEDROCK_AGENT_AGENT_RESOURCE_ROLE_ARN": "TEST_BEDROCK_AGENT_AGENT_RESOURCE_ROLE_ARN",
|
||||
"BEDROCK_AGENT_FOUNDATION_MODEL": "TEST_BEDROCK_AGENT_FOUNDATION_MODEL",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key not in exclude_list:
|
||||
monkeypatch.setenv(key, value)
|
||||
else:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kernel_with_function(kernel: Kernel, decorated_native_function: Callable) -> Kernel:
|
||||
kernel.add_function("test_plugin", function=decorated_native_function)
|
||||
|
||||
return kernel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def new_agent_name():
|
||||
return "test_agent_name"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bedrock_agent_model():
|
||||
return BedrockAgentModel(
|
||||
agent_name="test_agent_name",
|
||||
foundation_model="test_foundation_model",
|
||||
agent_status=BedrockAgentStatus.NOT_PREPARED,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bedrock_agent_model_with_id():
|
||||
return BedrockAgentModel(
|
||||
agent_id="test_agent_id",
|
||||
agent_name="test_agent_name",
|
||||
foundation_model="test_foundation_model",
|
||||
agent_status=BedrockAgentStatus.NOT_PREPARED,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bedrock_agent_model_with_id_prepared_dict():
|
||||
return {
|
||||
"agent": {
|
||||
"agentId": "test_agent_id",
|
||||
"agentName": "test_agent_name",
|
||||
"foundationModel": "test_foundation_model",
|
||||
"agentStatus": "PREPARED",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bedrock_agent_model_with_id_preparing_dict():
|
||||
return {
|
||||
"agent": {
|
||||
"agentId": "test_agent_id",
|
||||
"agentName": "test_agent_name",
|
||||
"foundationModel": "test_foundation_model",
|
||||
"agentStatus": "PREPARING",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bedrock_agent_model_with_id_not_prepared_dict():
|
||||
return {
|
||||
"agent": {
|
||||
"agentId": "test_agent_id",
|
||||
"agentName": "test_agent_name",
|
||||
"foundationModel": "test_foundation_model",
|
||||
"agentStatus": "NOT_PREPARED",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def existing_agent_not_prepared_model():
|
||||
return BedrockAgentModel(
|
||||
agent_id="test_agent_id",
|
||||
agent_name="test_agent_name",
|
||||
foundation_model="test_foundation_model",
|
||||
agent_status=BedrockAgentStatus.NOT_PREPARED,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bedrock_action_group_mode_dict():
|
||||
return {
|
||||
"agentActionGroup": {
|
||||
"actionGroupId": "test_action_group_id",
|
||||
"actionGroupName": "test_action_group_name",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_response():
|
||||
return "test response"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bedrock_agent_non_streaming_empty_response():
|
||||
return {
|
||||
"completion": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bedrock_agent_non_streaming_simple_response(simple_response):
|
||||
return {
|
||||
"completion": [
|
||||
{
|
||||
"chunk": {"bytes": bytes(simple_response, "utf-8")},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bedrock_agent_streaming_simple_response(simple_response):
|
||||
return {
|
||||
"completion": [
|
||||
{
|
||||
"chunk": {"bytes": bytes(chunk, "utf-8")},
|
||||
}
|
||||
for chunk in simple_response
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bedrock_agent_function_call_response():
|
||||
return {
|
||||
"completion": [
|
||||
{
|
||||
BedrockAgentEventType.RETURN_CONTROL: {
|
||||
"invocationId": "test_invocation_id",
|
||||
"invocationInputs": [
|
||||
{
|
||||
"functionInvocationInput": {
|
||||
"function": "test_function",
|
||||
"parameters": [
|
||||
{"name": "test_parameter_name", "value": "test_parameter_value"},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bedrock_agent_create_session_response():
|
||||
return {
|
||||
"sessionId": "test_session_id",
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.bedrock.action_group_utils import (
|
||||
BEDROCK_FUNCTION_ALLOWED_PARAMETER_TYPES,
|
||||
kernel_function_parameter_type_to_bedrock_function_parameter_type,
|
||||
kernel_function_to_bedrock_function_schema,
|
||||
parse_function_result_contents,
|
||||
parse_return_control_payload,
|
||||
)
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.contents.function_result_content import FunctionResultContent
|
||||
from semantic_kernel.kernel import Kernel
|
||||
|
||||
|
||||
def test_kernel_function_to_bedrock_function_schema(kernel_with_function: Kernel):
|
||||
# Test the conversion of kernel function to bedrock function schema
|
||||
function_choice_behavior = FunctionChoiceBehavior.Auto()
|
||||
function_choice_configuration = function_choice_behavior.get_config(kernel_with_function)
|
||||
result = kernel_function_to_bedrock_function_schema(function_choice_configuration)
|
||||
assert result == {
|
||||
"functions": [
|
||||
{
|
||||
"name": "test_plugin-getLightStatus",
|
||||
"parameters": {
|
||||
"arg1": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
}
|
||||
},
|
||||
"requireConfirmation": "DISABLED",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_kernel_function_parameter_type_to_bedrock_function_parameter_type():
|
||||
# Test the conversion of kernel function parameter type to bedrock function parameter type
|
||||
schema_data = {"type": "string"}
|
||||
result = kernel_function_parameter_type_to_bedrock_function_parameter_type(schema_data)
|
||||
assert result == "string"
|
||||
|
||||
|
||||
def test_kernel_function_parameter_type_to_bedrock_function_parameter_type_invalid():
|
||||
# Test the conversion of invalid kernel function parameter type to bedrock function parameter type
|
||||
schema_data = {"type": "invalid_type"}
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Type invalid_type is not allowed in bedrock function parameter type. "
|
||||
f"Allowed types are {BEDROCK_FUNCTION_ALLOWED_PARAMETER_TYPES}.",
|
||||
):
|
||||
kernel_function_parameter_type_to_bedrock_function_parameter_type(schema_data)
|
||||
|
||||
|
||||
def test_parse_return_control_payload():
|
||||
# Test the parsing of return control payload to function call contents
|
||||
return_control_payload = {
|
||||
"invocationId": "test_invocation_id",
|
||||
"invocationInputs": [
|
||||
{
|
||||
"functionInvocationInput": {
|
||||
"function": "test_function",
|
||||
"parameters": [
|
||||
{"name": "param1", "value": "value1"},
|
||||
{"name": "param2", "value": "value2"},
|
||||
],
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
result = parse_return_control_payload(return_control_payload)
|
||||
assert len(result) == 1
|
||||
assert result[0].id == "test_invocation_id"
|
||||
assert result[0].name == "test_function"
|
||||
assert result[0].arguments == {"param1": "value1", "param2": "value2"}
|
||||
|
||||
|
||||
def test_parse_function_result_contents():
|
||||
# Test the parsing of function result contents to be returned to the agent
|
||||
function_result_contents = [
|
||||
FunctionResultContent(
|
||||
id="test_id",
|
||||
name="test_function",
|
||||
result="test_result",
|
||||
metadata={"functionInvocationInput": {"actionGroup": "test_action_group"}},
|
||||
)
|
||||
]
|
||||
result = parse_function_result_contents(function_result_contents)
|
||||
assert len(result) == 1
|
||||
assert result[0]["functionResult"]["actionGroup"] == "test_action_group"
|
||||
assert result[0]["functionResult"]["function"] == "test_function"
|
||||
assert result[0]["functionResult"]["responseBody"]["TEXT"]["body"] == "test_result"
|
||||
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from semantic_kernel.agents.bedrock.models.bedrock_action_group_model import BedrockActionGroupModel
|
||||
|
||||
|
||||
def test_bedrock_action_group_model_valid():
|
||||
"""Test case to verify the BedrockActionGroupModel with valid data."""
|
||||
model = BedrockActionGroupModel(actionGroupId="test_id", actionGroupName="test_name")
|
||||
assert model.action_group_id == "test_id"
|
||||
assert model.action_group_name == "test_name"
|
||||
|
||||
|
||||
def test_bedrock_action_group_model_missing_action_group_id():
|
||||
"""Test case to verify error handling when actionGroupId is missing."""
|
||||
with pytest.raises(ValidationError):
|
||||
BedrockActionGroupModel(actionGroupName="test_name")
|
||||
|
||||
|
||||
def test_bedrock_action_group_model_missing_action_group_name():
|
||||
"""Test case to verify error handling when actionGroupName is missing."""
|
||||
with pytest.raises(ValidationError):
|
||||
BedrockActionGroupModel(actionGroupId="test_id")
|
||||
|
||||
|
||||
def test_bedrock_action_group_model_extra_field():
|
||||
"""Test case to verify the BedrockActionGroupModel with an extra field."""
|
||||
model = BedrockActionGroupModel(actionGroupId="test_id", actionGroupName="test_name", extraField="extra_value")
|
||||
assert model.action_group_id == "test_id"
|
||||
assert model.action_group_name == "test_name"
|
||||
assert model.extraField == "extra_value"
|
||||
@@ -0,0 +1,751 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, PropertyMock, patch
|
||||
|
||||
import boto3
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.bedrock.action_group_utils import (
|
||||
kernel_function_to_bedrock_function_schema,
|
||||
parse_function_result_contents,
|
||||
)
|
||||
from semantic_kernel.agents.bedrock.bedrock_agent import BedrockAgent, BedrockAgentThread
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.contents.function_result_content import FunctionResultContent
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentInitializationException, AgentInvokeException
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.kernel import Kernel
|
||||
|
||||
# region Agent Initialization Tests
|
||||
|
||||
|
||||
# Test case to verify BedrockAgent initialization
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_bedrock_agent_initialization(client, bedrock_agent_model_with_id):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id)
|
||||
|
||||
assert agent.name == bedrock_agent_model_with_id.agent_name
|
||||
assert agent.agent_model.agent_name == bedrock_agent_model_with_id.agent_name
|
||||
assert agent.agent_model.agent_id == bedrock_agent_model_with_id.agent_id
|
||||
assert agent.agent_model.foundation_model == bedrock_agent_model_with_id.foundation_model
|
||||
|
||||
|
||||
# Test case to verify error handling during BedrockAgent initialization with non-auto function choice
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_bedrock_agent_initialization_error_with_non_auto_function_choice(client, bedrock_agent_model_with_id):
|
||||
with pytest.raises(ValueError, match="Only FunctionChoiceType.AUTO is supported."):
|
||||
BedrockAgent(
|
||||
bedrock_agent_model_with_id,
|
||||
function_choice_behavior=FunctionChoiceBehavior.NoneInvoke(),
|
||||
)
|
||||
|
||||
|
||||
# Test case to verify the creation of BedrockAgent
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
@pytest.mark.parametrize(
|
||||
"kernel, function_choice_behavior, arguments",
|
||||
[
|
||||
(None, None, None),
|
||||
(Kernel(), None, None),
|
||||
(Kernel(), FunctionChoiceBehavior.Auto(), None),
|
||||
(Kernel(), FunctionChoiceBehavior.Auto(), KernelArguments()),
|
||||
],
|
||||
)
|
||||
async def test_bedrock_agent_create_and_prepare_agent(
|
||||
client,
|
||||
bedrock_agent_model_with_id_not_prepared_dict,
|
||||
bedrock_agent_unit_test_env,
|
||||
kernel,
|
||||
function_choice_behavior,
|
||||
arguments,
|
||||
):
|
||||
with (
|
||||
patch.object(client, "create_agent") as mock_create_agent,
|
||||
patch.object(BedrockAgent, "_wait_for_agent_status", new_callable=AsyncMock),
|
||||
patch.object(BedrockAgent, "prepare_agent_and_wait_until_prepared", new_callable=AsyncMock),
|
||||
):
|
||||
mock_create_agent.return_value = bedrock_agent_model_with_id_not_prepared_dict
|
||||
|
||||
agent = await BedrockAgent.create_and_prepare_agent(
|
||||
name=bedrock_agent_model_with_id_not_prepared_dict["agent"]["agentName"],
|
||||
instructions="test_instructions",
|
||||
bedrock_client=client,
|
||||
env_file_path="fake_path",
|
||||
kernel=kernel,
|
||||
function_choice_behavior=function_choice_behavior,
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
mock_create_agent.assert_called_once_with(
|
||||
agentName=bedrock_agent_model_with_id_not_prepared_dict["agent"]["agentName"],
|
||||
foundationModel=bedrock_agent_unit_test_env["BEDROCK_AGENT_FOUNDATION_MODEL"],
|
||||
agentResourceRoleArn=bedrock_agent_unit_test_env["BEDROCK_AGENT_AGENT_RESOURCE_ROLE_ARN"],
|
||||
instruction="test_instructions",
|
||||
)
|
||||
assert agent.agent_model.agent_id == bedrock_agent_model_with_id_not_prepared_dict["agent"]["agentId"]
|
||||
assert agent.id == bedrock_agent_model_with_id_not_prepared_dict["agent"]["agentId"]
|
||||
assert agent.agent_model.agent_name == bedrock_agent_model_with_id_not_prepared_dict["agent"]["agentName"]
|
||||
assert agent.name == bedrock_agent_model_with_id_not_prepared_dict["agent"]["agentName"]
|
||||
assert (
|
||||
agent.agent_model.foundation_model
|
||||
== bedrock_agent_model_with_id_not_prepared_dict["agent"]["foundationModel"]
|
||||
)
|
||||
assert agent.kernel is not None
|
||||
assert agent.function_choice_behavior is not None
|
||||
if arguments:
|
||||
assert agent.arguments is not None
|
||||
|
||||
|
||||
# Test case to verify the creation of BedrockAgent
|
||||
@pytest.mark.parametrize(
|
||||
"exclude_list",
|
||||
[
|
||||
["BEDROCK_AGENT_AGENT_RESOURCE_ROLE_ARN"],
|
||||
["BEDROCK_AGENT_FOUNDATION_MODEL"],
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_bedrock_agent_create_and_prepare_agent_settings_validation_error(
|
||||
client,
|
||||
bedrock_agent_model_with_id_not_prepared_dict,
|
||||
bedrock_agent_unit_test_env,
|
||||
):
|
||||
with pytest.raises(AgentInitializationException):
|
||||
await BedrockAgent.create_and_prepare_agent(
|
||||
name=bedrock_agent_model_with_id_not_prepared_dict["agent"]["agentName"],
|
||||
instructions="test_instructions",
|
||||
env_file_path="fake_path",
|
||||
)
|
||||
|
||||
|
||||
# Test case to verify the creation of BedrockAgent
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_bedrock_agent_create_and_prepare_agent_service_exception(
|
||||
client,
|
||||
bedrock_agent_model_with_id_not_prepared_dict,
|
||||
bedrock_agent_unit_test_env,
|
||||
):
|
||||
with (
|
||||
patch.object(client, "create_agent") as mock_create_agent,
|
||||
patch.object(BedrockAgent, "prepare_agent_and_wait_until_prepared", new_callable=AsyncMock),
|
||||
):
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
mock_create_agent.side_effect = ClientError({}, "create_agent")
|
||||
|
||||
with pytest.raises(AgentInitializationException):
|
||||
await BedrockAgent.create_and_prepare_agent(
|
||||
name=bedrock_agent_model_with_id_not_prepared_dict["agent"]["agentName"],
|
||||
instructions="test_instructions",
|
||||
bedrock_client=client,
|
||||
env_file_path="fake_path",
|
||||
)
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_bedrock_agent_prepare_agent_and_wait_until_prepared(
|
||||
client,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
bedrock_agent_model_with_id_preparing_dict,
|
||||
bedrock_agent_model_with_id_prepared_dict,
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id, bedrock_client=client)
|
||||
|
||||
with (
|
||||
patch.object(client, "get_agent") as mock_get_agent,
|
||||
patch.object(client, "prepare_agent") as mock_prepare_agent,
|
||||
):
|
||||
mock_get_agent.side_effect = [
|
||||
bedrock_agent_model_with_id_preparing_dict,
|
||||
bedrock_agent_model_with_id_prepared_dict,
|
||||
]
|
||||
|
||||
await agent.prepare_agent_and_wait_until_prepared()
|
||||
|
||||
mock_prepare_agent.assert_called_once_with(agentId=bedrock_agent_model_with_id.agent_id)
|
||||
assert mock_get_agent.call_count == 2
|
||||
assert agent.agent_model.agent_status == "PREPARED"
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_bedrock_agent_prepare_agent_and_wait_until_prepared_fail(
|
||||
client,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
bedrock_agent_model_with_id_preparing_dict,
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id, bedrock_client=client)
|
||||
|
||||
with (
|
||||
patch.object(client, "get_agent") as mock_get_agent,
|
||||
patch.object(client, "prepare_agent"),
|
||||
):
|
||||
mock_get_agent.side_effect = [
|
||||
bedrock_agent_model_with_id_preparing_dict,
|
||||
bedrock_agent_model_with_id_preparing_dict,
|
||||
bedrock_agent_model_with_id_preparing_dict,
|
||||
bedrock_agent_model_with_id_preparing_dict,
|
||||
bedrock_agent_model_with_id_preparing_dict,
|
||||
bedrock_agent_model_with_id_preparing_dict,
|
||||
]
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
await agent.prepare_agent_and_wait_until_prepared()
|
||||
|
||||
|
||||
# Test case to verify the creation of a code interpreter action group
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_create_code_interpreter_action_group(
|
||||
client,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
bedrock_action_group_mode_dict,
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id, bedrock_client=client)
|
||||
|
||||
with (
|
||||
patch.object(client, "create_agent_action_group") as mock_create_action_group,
|
||||
patch.object(
|
||||
BedrockAgent, "prepare_agent_and_wait_until_prepared"
|
||||
) as mock_prepare_agent_and_wait_until_prepared,
|
||||
):
|
||||
mock_create_action_group.return_value = bedrock_action_group_mode_dict
|
||||
action_group_model = await agent.create_code_interpreter_action_group()
|
||||
|
||||
mock_create_action_group.assert_called_once_with(
|
||||
agentId=agent.agent_model.agent_id,
|
||||
agentVersion=agent.agent_model.agent_version or "DRAFT",
|
||||
actionGroupName=f"{agent.agent_model.agent_name}_code_interpreter",
|
||||
actionGroupState="ENABLED",
|
||||
parentActionGroupSignature="AMAZON.CodeInterpreter",
|
||||
)
|
||||
assert action_group_model.action_group_id == bedrock_action_group_mode_dict["agentActionGroup"]["actionGroupId"]
|
||||
mock_prepare_agent_and_wait_until_prepared.assert_called_once()
|
||||
|
||||
|
||||
# Test case to verify the creation of BedrockAgent with plugins
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_bedrock_agent_create_with_plugin_via_constructor(
|
||||
client,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
custom_plugin_class,
|
||||
):
|
||||
agent = BedrockAgent(
|
||||
bedrock_agent_model_with_id,
|
||||
plugins=[custom_plugin_class()],
|
||||
bedrock_client=client,
|
||||
)
|
||||
|
||||
assert agent.kernel.plugins is not None
|
||||
assert len(agent.kernel.plugins) == 1
|
||||
|
||||
|
||||
# Test case to verify the creation of a user input action group
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_create_user_input_action_group(
|
||||
client,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
bedrock_action_group_mode_dict,
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id, bedrock_client=client)
|
||||
|
||||
with (
|
||||
patch.object(agent.bedrock_client, "create_agent_action_group") as mock_create_action_group,
|
||||
patch.object(
|
||||
BedrockAgent, "prepare_agent_and_wait_until_prepared"
|
||||
) as mock_prepare_agent_and_wait_until_prepared,
|
||||
):
|
||||
mock_create_action_group.return_value = bedrock_action_group_mode_dict
|
||||
action_group_model = await agent.create_user_input_action_group()
|
||||
|
||||
mock_create_action_group.assert_called_once_with(
|
||||
agentId=agent.agent_model.agent_id,
|
||||
agentVersion=agent.agent_model.agent_version or "DRAFT",
|
||||
actionGroupName=f"{agent.agent_model.agent_name}_user_input",
|
||||
actionGroupState="ENABLED",
|
||||
parentActionGroupSignature="AMAZON.UserInput",
|
||||
)
|
||||
assert action_group_model.action_group_id == bedrock_action_group_mode_dict["agentActionGroup"]["actionGroupId"]
|
||||
mock_prepare_agent_and_wait_until_prepared.assert_called_once()
|
||||
|
||||
|
||||
# Test case to verify the creation of a kernel function action group
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_create_kernel_function_action_group(
|
||||
client,
|
||||
kernel_with_function,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
bedrock_action_group_mode_dict,
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id, kernel=kernel_with_function, bedrock_client=client)
|
||||
|
||||
with (
|
||||
patch.object(agent.bedrock_client, "create_agent_action_group") as mock_create_action_group,
|
||||
patch.object(
|
||||
BedrockAgent, "prepare_agent_and_wait_until_prepared"
|
||||
) as mock_prepare_agent_and_wait_until_prepared,
|
||||
):
|
||||
mock_create_action_group.return_value = bedrock_action_group_mode_dict
|
||||
|
||||
action_group_model = await agent.create_kernel_function_action_group()
|
||||
|
||||
mock_create_action_group.assert_called_once_with(
|
||||
agentId=agent.agent_model.agent_id,
|
||||
agentVersion=agent.agent_model.agent_version or "DRAFT",
|
||||
actionGroupName=f"{agent.agent_model.agent_name}_kernel_function",
|
||||
actionGroupState="ENABLED",
|
||||
actionGroupExecutor={"customControl": "RETURN_CONTROL"},
|
||||
functionSchema=kernel_function_to_bedrock_function_schema(
|
||||
agent.function_choice_behavior.get_config(kernel_with_function)
|
||||
),
|
||||
)
|
||||
assert action_group_model.action_group_id == bedrock_action_group_mode_dict["agentActionGroup"]["actionGroupId"]
|
||||
mock_prepare_agent_and_wait_until_prepared.assert_called_once()
|
||||
|
||||
|
||||
# Test case to verify the association of an agent with a knowledge base
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_associate_agent_knowledge_base(
|
||||
client,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id, bedrock_client=client)
|
||||
|
||||
with (
|
||||
patch.object(agent.bedrock_client, "associate_agent_knowledge_base") as mock_associate_knowledge_base,
|
||||
patch.object(
|
||||
BedrockAgent, "prepare_agent_and_wait_until_prepared"
|
||||
) as mock_prepare_agent_and_wait_until_prepared,
|
||||
):
|
||||
await agent.associate_agent_knowledge_base("test_knowledge_base_id")
|
||||
|
||||
mock_associate_knowledge_base.assert_called_once_with(
|
||||
agentId=agent.agent_model.agent_id,
|
||||
agentVersion=agent.agent_model.agent_version,
|
||||
knowledgeBaseId="test_knowledge_base_id",
|
||||
)
|
||||
mock_prepare_agent_and_wait_until_prepared.assert_called_once()
|
||||
|
||||
|
||||
# Test case to verify the disassociation of an agent with a knowledge base
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_disassociate_agent_knowledge_base(
|
||||
client,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id, bedrock_client=client)
|
||||
|
||||
with (
|
||||
patch.object(agent.bedrock_client, "disassociate_agent_knowledge_base") as mock_disassociate_knowledge_base,
|
||||
patch.object(
|
||||
BedrockAgent, "prepare_agent_and_wait_until_prepared"
|
||||
) as mock_prepare_agent_and_wait_until_prepared,
|
||||
):
|
||||
await agent.disassociate_agent_knowledge_base("test_knowledge_base_id")
|
||||
mock_disassociate_knowledge_base.assert_called_once_with(
|
||||
agentId=agent.agent_model.agent_id,
|
||||
agentVersion=agent.agent_model.agent_version,
|
||||
knowledgeBaseId="test_knowledge_base_id",
|
||||
)
|
||||
mock_prepare_agent_and_wait_until_prepared.assert_called_once()
|
||||
|
||||
|
||||
# Test case to verify listing associated knowledge bases with an agent
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_list_associated_agent_knowledge_bases(
|
||||
client,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id, bedrock_client=client)
|
||||
|
||||
with patch.object(agent.bedrock_client, "list_agent_knowledge_bases") as mock_list_knowledge_bases:
|
||||
await agent.list_associated_agent_knowledge_bases()
|
||||
|
||||
mock_list_knowledge_bases.assert_called_once_with(
|
||||
agentId=agent.agent_model.agent_id,
|
||||
agentVersion=agent.agent_model.agent_version,
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Agent Deletion Tests
|
||||
|
||||
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_delete_agent(
|
||||
client,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id, bedrock_client=client)
|
||||
|
||||
agent_id = bedrock_agent_model_with_id.agent_id
|
||||
with patch.object(agent.bedrock_client, "delete_agent") as mock_delete_agent:
|
||||
await agent.delete_agent()
|
||||
|
||||
mock_delete_agent.assert_called_once_with(agentId=agent_id)
|
||||
assert agent.agent_model.agent_id is None
|
||||
|
||||
|
||||
# Test case to verify error handling when deleting an agent that does not exist
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_delete_agent_twice_error(
|
||||
client,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id, bedrock_client=client)
|
||||
|
||||
with patch.object(agent.bedrock_client, "delete_agent"):
|
||||
await agent.delete_agent()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await agent.delete_agent()
|
||||
|
||||
|
||||
# Test case to verify error handling when there is a client error during agent deletion
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_delete_agent_client_error(
|
||||
client,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id, bedrock_client=client)
|
||||
|
||||
with patch.object(agent.bedrock_client, "delete_agent") as mock_delete_agent:
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
mock_delete_agent.side_effect = ClientError({"Error": {"Code": "500"}}, "delete_agent")
|
||||
|
||||
with pytest.raises(ClientError):
|
||||
await agent.delete_agent()
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Agent Invoke Tests
|
||||
|
||||
|
||||
# Test case to verify the `get_response` method of BedrockAgent
|
||||
@pytest.mark.parametrize(
|
||||
"thread",
|
||||
[
|
||||
None,
|
||||
BedrockAgentThread(None, session_id="test_session_id"),
|
||||
],
|
||||
)
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_bedrock_agent_get_response(
|
||||
client,
|
||||
thread,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
bedrock_agent_non_streaming_simple_response,
|
||||
simple_response,
|
||||
):
|
||||
with (
|
||||
patch.object(BedrockAgent, "_invoke_agent", new_callable=AsyncMock) as mock_invoke_agent,
|
||||
patch.object(BedrockAgentThread, "create", new_callable=AsyncMock) as mock_start,
|
||||
patch(
|
||||
"semantic_kernel.agents.bedrock.bedrock_agent.BedrockAgentThread.id",
|
||||
new_callable=PropertyMock,
|
||||
) as mock_id,
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id)
|
||||
mock_id.return_value = "mock-thread-id"
|
||||
|
||||
mock_invoke_agent.return_value = bedrock_agent_non_streaming_simple_response
|
||||
mock_start.return_value = "test_session_id"
|
||||
|
||||
response = await agent.get_response(messages="test_input_text", thread=thread)
|
||||
assert response.message.content == simple_response
|
||||
|
||||
mock_invoke_agent.assert_called_once()
|
||||
|
||||
|
||||
# Test case to verify the `get_response` method of BedrockAgent
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_bedrock_agent_get_response_exception(
|
||||
client,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
bedrock_agent_non_streaming_empty_response,
|
||||
):
|
||||
with (
|
||||
patch.object(BedrockAgent, "_invoke_agent", new_callable=AsyncMock) as mock_invoke_agent,
|
||||
patch.object(BedrockAgentThread, "create", new_callable=AsyncMock) as mock_start,
|
||||
patch(
|
||||
"semantic_kernel.agents.bedrock.bedrock_agent.BedrockAgentThread.id",
|
||||
new_callable=PropertyMock,
|
||||
) as mock_id,
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id)
|
||||
mock_id.return_value = "mock-thread-id"
|
||||
|
||||
mock_invoke_agent.return_value = bedrock_agent_non_streaming_empty_response
|
||||
mock_start.return_value = "test_session_id"
|
||||
|
||||
with pytest.raises(AgentInvokeException):
|
||||
await agent.get_response(messages="test_input_text")
|
||||
|
||||
|
||||
# Test case to verify the invocation of BedrockAgent
|
||||
@pytest.mark.parametrize(
|
||||
"thread",
|
||||
[
|
||||
None,
|
||||
BedrockAgentThread(None, session_id="test_session_id"),
|
||||
],
|
||||
)
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_bedrock_agent_invoke(
|
||||
client,
|
||||
thread,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
bedrock_agent_non_streaming_simple_response,
|
||||
simple_response,
|
||||
):
|
||||
with (
|
||||
patch.object(BedrockAgent, "_invoke_agent", new_callable=AsyncMock) as mock_invoke_agent,
|
||||
patch.object(BedrockAgentThread, "create", new_callable=AsyncMock) as mock_start,
|
||||
patch.object(BedrockAgentThread, "id", "test_session_id"),
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id)
|
||||
|
||||
mock_invoke_agent.return_value = bedrock_agent_non_streaming_simple_response
|
||||
mock_start.return_value = "test_session_id"
|
||||
|
||||
async for response in agent.invoke(messages="test_input_text", thread=thread):
|
||||
assert response.message.content == simple_response
|
||||
|
||||
mock_invoke_agent.assert_called_once_with(
|
||||
"test_session_id",
|
||||
"test_input_text",
|
||||
None,
|
||||
streamingConfigurations={"streamFinalResponse": False},
|
||||
sessionState={},
|
||||
)
|
||||
|
||||
|
||||
# Test case to verify the streaming invocation of BedrockAgent
|
||||
@pytest.mark.parametrize(
|
||||
"thread",
|
||||
[
|
||||
None,
|
||||
BedrockAgentThread(None, session_id="test_session_id"),
|
||||
],
|
||||
)
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_bedrock_agent_invoke_stream(
|
||||
client,
|
||||
thread,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
bedrock_agent_streaming_simple_response,
|
||||
simple_response,
|
||||
):
|
||||
with (
|
||||
patch.object(BedrockAgent, "_invoke_agent", new_callable=AsyncMock) as mock_invoke_agent,
|
||||
patch.object(BedrockAgentThread, "create", new_callable=AsyncMock) as mock_start,
|
||||
patch.object(BedrockAgentThread, "id", "test_session_id"),
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id)
|
||||
|
||||
mock_invoke_agent.return_value = bedrock_agent_streaming_simple_response
|
||||
mock_start.return_value = "test_session_id"
|
||||
|
||||
full_message = ""
|
||||
async for response in agent.invoke_stream(messages="test_input_text", thread=thread):
|
||||
full_message += response.message.content
|
||||
|
||||
assert full_message == simple_response
|
||||
mock_invoke_agent.assert_called_once_with(
|
||||
"test_session_id",
|
||||
"test_input_text",
|
||||
None,
|
||||
streamingConfigurations={"streamFinalResponse": True},
|
||||
sessionState={},
|
||||
)
|
||||
|
||||
|
||||
# Test case to verify the invocation of BedrockAgent with function call
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_bedrock_agent_invoke_with_function_call(
|
||||
client,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
bedrock_agent_function_call_response,
|
||||
bedrock_agent_non_streaming_simple_response,
|
||||
):
|
||||
with (
|
||||
patch.object(BedrockAgent, "_invoke_agent", new_callable=AsyncMock) as mock_invoke_agent,
|
||||
patch.object(BedrockAgent, "_handle_function_call_contents") as mock_handle_function_call_contents,
|
||||
patch.object(BedrockAgentThread, "create", new_callable=AsyncMock) as mock_start,
|
||||
patch.object(BedrockAgentThread, "id", "test_session_id"),
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id)
|
||||
|
||||
function_result_contents = [
|
||||
FunctionResultContent(
|
||||
id="test_id",
|
||||
name="test_function",
|
||||
result="test_result",
|
||||
metadata={"functionInvocationInput": {"actionGroup": "test_action_group"}},
|
||||
)
|
||||
]
|
||||
mock_handle_function_call_contents.return_value = function_result_contents
|
||||
agent.function_choice_behavior.maximum_auto_invoke_attempts = 2
|
||||
|
||||
mock_invoke_agent.side_effect = [
|
||||
bedrock_agent_function_call_response,
|
||||
bedrock_agent_non_streaming_simple_response,
|
||||
]
|
||||
mock_start.return_value = "test_session_id"
|
||||
async for _ in agent.invoke(messages="test_input_text"):
|
||||
mock_invoke_agent.assert_called_with(
|
||||
"test_session_id",
|
||||
"test_input_text",
|
||||
None,
|
||||
streamingConfigurations={"streamFinalResponse": False},
|
||||
sessionState={
|
||||
"invocationId": "test_invocation_id",
|
||||
"returnControlInvocationResults": parse_function_result_contents(function_result_contents),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Test case to verify the streaming invocation of BedrockAgent with function call
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
async def test_bedrock_agent_invoke_stream_with_function_call(
|
||||
client,
|
||||
bedrock_agent_unit_test_env,
|
||||
bedrock_agent_model_with_id,
|
||||
bedrock_agent_function_call_response,
|
||||
bedrock_agent_streaming_simple_response,
|
||||
):
|
||||
with (
|
||||
patch.object(BedrockAgent, "_invoke_agent", new_callable=AsyncMock) as mock_invoke_agent,
|
||||
patch.object(BedrockAgent, "_handle_function_call_contents") as mock_handle_function_call_contents,
|
||||
patch.object(BedrockAgentThread, "create", new_callable=AsyncMock) as mock_start,
|
||||
patch.object(BedrockAgentThread, "id", "test_session_id"),
|
||||
):
|
||||
agent = BedrockAgent(bedrock_agent_model_with_id)
|
||||
|
||||
function_result_contents = [
|
||||
FunctionResultContent(
|
||||
id="test_id",
|
||||
name="test_function",
|
||||
result="test_result",
|
||||
metadata={"functionInvocationInput": {"actionGroup": "test_action_group"}},
|
||||
)
|
||||
]
|
||||
mock_handle_function_call_contents.return_value = function_result_contents
|
||||
agent.function_choice_behavior.maximum_auto_invoke_attempts = 2
|
||||
|
||||
mock_invoke_agent.side_effect = [
|
||||
bedrock_agent_function_call_response,
|
||||
bedrock_agent_streaming_simple_response,
|
||||
]
|
||||
mock_start.return_value = "test_session_id"
|
||||
async for _ in agent.invoke_stream(messages="test_input_text"):
|
||||
mock_invoke_agent.assert_called_with(
|
||||
"test_session_id",
|
||||
"test_input_text",
|
||||
None,
|
||||
streamingConfigurations={"streamFinalResponse": True},
|
||||
sessionState={
|
||||
"invocationId": "test_invocation_id",
|
||||
"returnControlInvocationResults": parse_function_result_contents(function_result_contents),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Filename Sanitization Tests
|
||||
|
||||
|
||||
def test_sanitize_filename_simple():
|
||||
"""Test _sanitize_filename with a simple filename."""
|
||||
assert BedrockAgent._sanitize_filename("file.txt") == "file.txt"
|
||||
|
||||
|
||||
def test_sanitize_filename_with_spaces():
|
||||
"""Test _sanitize_filename with spaces in filename."""
|
||||
assert BedrockAgent._sanitize_filename("my file.txt") == "my file.txt"
|
||||
|
||||
|
||||
def test_sanitize_filename_directory_traversal_unix():
|
||||
"""Test _sanitize_filename strips Unix-style directory traversal."""
|
||||
assert BedrockAgent._sanitize_filename("../../../etc/passwd") == "passwd"
|
||||
assert BedrockAgent._sanitize_filename("../../file.txt") == "file.txt"
|
||||
assert BedrockAgent._sanitize_filename("/etc/passwd") == "passwd"
|
||||
|
||||
|
||||
def test_sanitize_filename_directory_traversal_windows():
|
||||
"""Test _sanitize_filename strips Windows-style directory traversal."""
|
||||
assert BedrockAgent._sanitize_filename("..\\..\\..\\Windows\\System32\\config") == "config"
|
||||
assert BedrockAgent._sanitize_filename("C:\\Users\\file.txt") == "file.txt"
|
||||
assert BedrockAgent._sanitize_filename("\\\\server\\share\\file.txt") == "file.txt"
|
||||
|
||||
|
||||
def test_sanitize_filename_mixed_separators():
|
||||
"""Test _sanitize_filename with mixed path separators."""
|
||||
assert BedrockAgent._sanitize_filename("../path\\to/file.txt") == "file.txt"
|
||||
assert BedrockAgent._sanitize_filename("..\\path/to\\file.txt") == "file.txt"
|
||||
|
||||
|
||||
def test_sanitize_filename_null_byte():
|
||||
"""Test _sanitize_filename removes null bytes."""
|
||||
assert BedrockAgent._sanitize_filename("file\x00.txt") == "file.txt"
|
||||
assert BedrockAgent._sanitize_filename("file.txt\x00.exe") == "file.txt.exe"
|
||||
|
||||
|
||||
def test_sanitize_filename_empty():
|
||||
"""Test _sanitize_filename returns empty string for empty result."""
|
||||
assert BedrockAgent._sanitize_filename("") == ""
|
||||
assert BedrockAgent._sanitize_filename("../") == ""
|
||||
assert BedrockAgent._sanitize_filename("..\\") == ""
|
||||
|
||||
|
||||
def test_sanitize_filename_only_dots():
|
||||
"""Test _sanitize_filename handles edge cases with dots."""
|
||||
# Note: os.path.basename("..") returns ".." which is kept as-is
|
||||
# Only "../" or "..\" patterns get stripped to empty string
|
||||
assert BedrockAgent._sanitize_filename(".") == "."
|
||||
|
||||
|
||||
def test_sanitize_filename_logs_warning(caplog):
|
||||
"""Test _sanitize_filename logs warning when filename is sanitized."""
|
||||
import logging
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = BedrockAgent._sanitize_filename("../malicious/file.txt")
|
||||
assert result == "file.txt"
|
||||
assert "potentially malicious path components" in caplog.text
|
||||
assert "../malicious/file.txt" in caplog.text
|
||||
assert "file.txt" in caplog.text
|
||||
|
||||
|
||||
def test_sanitize_filename_no_warning_for_clean_filename(caplog):
|
||||
"""Test _sanitize_filename does not log warning for clean filenames."""
|
||||
import logging
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = BedrockAgent._sanitize_filename("clean_file.txt")
|
||||
assert result == "clean_file.txt"
|
||||
assert "potentially malicious" not in caplog.text
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,331 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import boto3
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.agent import Agent, AgentResponseItem, AgentThread
|
||||
from semantic_kernel.agents.bedrock.models.bedrock_agent_model import BedrockAgentModel
|
||||
from semantic_kernel.agents.channels.bedrock_agent_channel import BedrockAgentChannel
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentChatException
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@patch.object(boto3, "client", return_value=Mock())
|
||||
def mock_channel(client):
|
||||
from semantic_kernel.agents.bedrock.bedrock_agent import BedrockAgentThread
|
||||
|
||||
BedrockAgentChannel.model_rebuild()
|
||||
thread = BedrockAgentThread(client, session_id="test_session_id")
|
||||
|
||||
return BedrockAgentChannel(thread=thread)
|
||||
|
||||
|
||||
class ConcreteAgent(Agent):
|
||||
async def get_response(self, *args, **kwargs) -> ChatMessageContent:
|
||||
raise NotImplementedError
|
||||
|
||||
def invoke(self, *args, **kwargs) -> AsyncIterable[ChatMessageContent]:
|
||||
raise NotImplementedError
|
||||
|
||||
def invoke_stream(self, *args, **kwargs) -> AsyncIterable[StreamingChatMessageContent]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chat_history() -> list[ChatMessageContent]:
|
||||
return [
|
||||
ChatMessageContent(role="user", content="Hello, Bedrock!"),
|
||||
ChatMessageContent(role="assistant", content="Hello, User!"),
|
||||
ChatMessageContent(role="user", content="How are you?"),
|
||||
ChatMessageContent(role="assistant", content="I'm good, thank you!"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chat_history_not_alternate_role() -> list[ChatMessageContent]:
|
||||
return [
|
||||
ChatMessageContent(role="user", content="Hello, Bedrock!"),
|
||||
ChatMessageContent(role="user", content="Hello, User!"),
|
||||
ChatMessageContent(role="assistant", content="How are you?"),
|
||||
ChatMessageContent(role="assistant", content="I'm good, thank you!"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_agent():
|
||||
"""
|
||||
Fixture that creates a mock BedrockAgent.
|
||||
"""
|
||||
from semantic_kernel.agents.bedrock.bedrock_agent import BedrockAgent
|
||||
|
||||
# Create mocks
|
||||
mock_agent = MagicMock(spec=BedrockAgent)
|
||||
# Set the name and agent_model properties
|
||||
mock_agent.name = "MockBedrockAgent"
|
||||
mock_agent.agent_model = MagicMock(spec=BedrockAgentModel)
|
||||
mock_agent.agent_model.foundation_model = "mock-foundation-model"
|
||||
|
||||
return mock_agent
|
||||
|
||||
|
||||
async def test_receive_message(mock_channel, chat_history):
|
||||
# Test to verify the receive_message functionality
|
||||
await mock_channel.receive(chat_history)
|
||||
assert len(mock_channel) == len(chat_history)
|
||||
|
||||
|
||||
async def test_channel_receive_message_with_no_message(mock_channel):
|
||||
# Test to verify receive_message when no message is received
|
||||
await mock_channel.receive([])
|
||||
assert len(mock_channel) == 0
|
||||
|
||||
|
||||
async def test_chat_history_alternation(mock_channel, chat_history_not_alternate_role):
|
||||
# Test to verify chat history alternates between user and assistant messages
|
||||
await mock_channel.receive(chat_history_not_alternate_role)
|
||||
assert all(
|
||||
mock_channel.messages[i].role != mock_channel.messages[i + 1].role
|
||||
for i in range(len(chat_history_not_alternate_role) - 1)
|
||||
)
|
||||
assert mock_channel.messages[1].content == mock_channel.MESSAGE_PLACEHOLDER
|
||||
assert mock_channel.messages[4].content == mock_channel.MESSAGE_PLACEHOLDER
|
||||
|
||||
|
||||
async def test_channel_reset(mock_channel, chat_history):
|
||||
# Test to verify the reset functionality
|
||||
await mock_channel.receive(chat_history)
|
||||
assert len(mock_channel) == len(chat_history)
|
||||
assert len(mock_channel.messages) == len(chat_history)
|
||||
await mock_channel.reset()
|
||||
assert len(mock_channel) == 0
|
||||
assert len(mock_channel.messages) == 0
|
||||
|
||||
|
||||
async def test_receive_appends_history_correctly(mock_channel):
|
||||
"""Test that the receive method appends messages while ensuring they alternate in author role."""
|
||||
# Provide a list of messages with identical roles to see if placeholders are inserted
|
||||
incoming_messages = [
|
||||
ChatMessageContent(role=AuthorRole.USER, content="User message 1"),
|
||||
ChatMessageContent(role=AuthorRole.USER, content="User message 2"),
|
||||
ChatMessageContent(role=AuthorRole.ASSISTANT, content="Assistant message 1"),
|
||||
ChatMessageContent(role=AuthorRole.ASSISTANT, content="Assistant message 2"),
|
||||
]
|
||||
|
||||
await mock_channel.receive(incoming_messages)
|
||||
|
||||
# The final channel.messages should be:
|
||||
# user message 1, user placeholder, user message 2, assistant placeholder, assistant message 1,
|
||||
# assistant placeholder, assistant message 2
|
||||
expected_roles = [
|
||||
AuthorRole.USER,
|
||||
AuthorRole.ASSISTANT, # placeholder
|
||||
AuthorRole.USER,
|
||||
AuthorRole.ASSISTANT,
|
||||
AuthorRole.USER, # placeholder
|
||||
AuthorRole.ASSISTANT,
|
||||
]
|
||||
expected_contents = [
|
||||
"User message 1",
|
||||
BedrockAgentChannel.MESSAGE_PLACEHOLDER,
|
||||
"User message 2",
|
||||
"Assistant message 1",
|
||||
BedrockAgentChannel.MESSAGE_PLACEHOLDER,
|
||||
"Assistant message 2",
|
||||
]
|
||||
|
||||
assert len(mock_channel.messages) == len(expected_roles)
|
||||
for i, (msg, exp_role, exp_content) in enumerate(zip(mock_channel.messages, expected_roles, expected_contents)):
|
||||
assert msg.role == exp_role, f"Role mismatch at index {i}"
|
||||
assert msg.content == exp_content, f"Content mismatch at index {i}"
|
||||
|
||||
|
||||
async def test_invoke_raises_exception_for_non_bedrock_agent(mock_channel):
|
||||
"""Test invoke method raises AgentChatException if the agent provided is not a BedrockAgent."""
|
||||
# Place a message in the channel so it's not empty
|
||||
mock_channel.messages.append(ChatMessageContent(role=AuthorRole.USER, content="User message"))
|
||||
|
||||
# Create a dummy agent that is not BedrockAgent
|
||||
non_bedrock_agent = ConcreteAgent()
|
||||
|
||||
with pytest.raises(AgentChatException) as exc_info:
|
||||
_ = [msg async for msg in mock_channel.invoke(non_bedrock_agent)]
|
||||
|
||||
assert "Agent is not of the expected type" in str(exc_info.value)
|
||||
|
||||
|
||||
async def test_invoke_raises_exception_if_no_history(mock_channel, mock_agent):
|
||||
"""Test invoke method raises AgentChatException if no chat history is available."""
|
||||
with pytest.raises(AgentChatException) as exc_info:
|
||||
_ = [msg async for msg in mock_channel.invoke(mock_agent)]
|
||||
|
||||
assert "No chat history available" in str(exc_info.value)
|
||||
|
||||
|
||||
async def test_invoke_inserts_placeholders_when_history_needs_to_alternate(mock_channel, mock_agent):
|
||||
"""Test invoke ensures _ensure_history_alternates and _ensure_last_message_is_user are called."""
|
||||
# Put messages in the channel such that the last message is an assistant's
|
||||
mock_channel.messages.append(ChatMessageContent(role=AuthorRole.ASSISTANT, content="Assistant 1"))
|
||||
|
||||
# Mock agent.invoke to return an async generator
|
||||
async def mock_invoke(messages: str, thread: AgentThread, sessionState=None, **kwargs):
|
||||
# We just yield one message as if the agent responded
|
||||
yield AgentResponseItem(
|
||||
message=ChatMessageContent(role=AuthorRole.ASSISTANT, content="Mock Agent Response"),
|
||||
thread=mock_channel.thread,
|
||||
)
|
||||
|
||||
mock_agent.invoke = mock_invoke
|
||||
|
||||
# Because the last message is from the assistant, we expect a placeholder user message to be appended
|
||||
# also the history might need to alternate.
|
||||
# But since there's only one message, there's nothing to fix except the last message is user.
|
||||
|
||||
# We will now add a user message so we do not get the "No chat history available" error
|
||||
mock_channel.messages.append(ChatMessageContent(role=AuthorRole.USER, content="User 1"))
|
||||
|
||||
# Now we do invoke
|
||||
outputs = [msg async for msg in mock_channel.invoke(mock_agent)]
|
||||
|
||||
# We'll check if the response is appended to channel.messages
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0][0] is True, "Expected a user-facing response"
|
||||
agent_response = outputs[0][1]
|
||||
assert agent_response.content == "Mock Agent Response"
|
||||
|
||||
# The channel messages should now have 3 messages: the assistant, the user, and the new agent message
|
||||
assert len(mock_channel.messages) == 3
|
||||
assert mock_channel.messages[-1].role == AuthorRole.ASSISTANT
|
||||
assert mock_channel.messages[-1].content == "Mock Agent Response"
|
||||
|
||||
|
||||
async def test_invoke_stream_raises_error_for_non_bedrock_agent(mock_channel):
|
||||
"""Test invoke_stream raises AgentChatException if the agent provided is not a BedrockAgent."""
|
||||
mock_channel.messages.append(ChatMessageContent(role=AuthorRole.USER, content="User message"))
|
||||
|
||||
non_bedrock_agent = ConcreteAgent()
|
||||
|
||||
with pytest.raises(AgentChatException) as exc_info:
|
||||
_ = [msg async for msg in mock_channel.invoke_stream(non_bedrock_agent, [])]
|
||||
|
||||
assert "Agent is not of the expected type" in str(exc_info.value)
|
||||
|
||||
|
||||
async def test_invoke_stream_raises_no_chat_history(mock_channel, mock_agent):
|
||||
"""Test invoke_stream raises AgentChatException if no messages in the channel."""
|
||||
|
||||
with pytest.raises(AgentChatException) as exc_info:
|
||||
_ = [msg async for msg in mock_channel.invoke_stream(mock_agent, [])]
|
||||
|
||||
assert "No chat history available." in str(exc_info.value)
|
||||
|
||||
|
||||
async def test_invoke_stream_appends_response_message(mock_channel, mock_agent):
|
||||
"""Test invoke_stream properly yields streaming content and appends an aggregated message at the end."""
|
||||
# Put a user message in the channel so it won't raise No chat history
|
||||
mock_channel.messages.append(ChatMessageContent(role=AuthorRole.USER, content="Last user message"))
|
||||
|
||||
async def mock_invoke_stream(
|
||||
messages: str, thread: AgentThread, sessionState=None, **kwargs
|
||||
) -> AsyncIterable[StreamingChatMessageContent]:
|
||||
yield AgentResponseItem(
|
||||
message=StreamingChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
choice_index=0,
|
||||
content="Hello",
|
||||
),
|
||||
thread=mock_channel.thread,
|
||||
)
|
||||
yield AgentResponseItem(
|
||||
message=StreamingChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
choice_index=0,
|
||||
content=" World",
|
||||
),
|
||||
thread=mock_channel.thread,
|
||||
)
|
||||
|
||||
mock_agent.invoke_stream = mock_invoke_stream
|
||||
|
||||
# Check that we get the streamed messages and that the summarized message is appended afterward
|
||||
messages_param = [ChatMessageContent(role=AuthorRole.USER, content="Last user message")] # just to pass the param
|
||||
streamed_content = [msg async for msg in mock_channel.invoke_stream(mock_agent, messages_param)]
|
||||
|
||||
# We expect two streamed chunks: "Hello" and " World"
|
||||
assert len(streamed_content) == 2
|
||||
assert streamed_content[0].content == "Hello"
|
||||
assert streamed_content[1].content == " World"
|
||||
|
||||
# Then we expect the channel to append an aggregated ChatMessageContent with "Hello World"
|
||||
assert len(messages_param) == 2
|
||||
appended = messages_param[1]
|
||||
assert appended.role == AuthorRole.ASSISTANT
|
||||
assert appended.content == "Hello World"
|
||||
|
||||
|
||||
async def test_get_history(mock_channel, chat_history):
|
||||
"""Test get_history yields messages in reverse order."""
|
||||
|
||||
mock_channel.messages = chat_history
|
||||
|
||||
reversed_history = [msg async for msg in mock_channel.get_history()]
|
||||
|
||||
# Should be reversed
|
||||
assert reversed_history[0].content == "I'm good, thank you!"
|
||||
assert reversed_history[1].content == "How are you?"
|
||||
assert reversed_history[2].content == "Hello, User!"
|
||||
assert reversed_history[3].content == "Hello, Bedrock!"
|
||||
|
||||
|
||||
async def test_invoke_alternates_history_and_ensures_last_user_message(mock_channel, mock_agent):
|
||||
"""Test invoke method ensures history alternates and last message is user."""
|
||||
mock_channel.messages = [
|
||||
ChatMessageContent(role=AuthorRole.USER, content="User1"),
|
||||
ChatMessageContent(role=AuthorRole.USER, content="User2"),
|
||||
ChatMessageContent(role=AuthorRole.ASSISTANT, content="Assist1"),
|
||||
ChatMessageContent(role=AuthorRole.ASSISTANT, content="Assist2"),
|
||||
ChatMessageContent(role=AuthorRole.USER, content="User3"),
|
||||
ChatMessageContent(role=AuthorRole.USER, content="User4"),
|
||||
ChatMessageContent(role=AuthorRole.ASSISTANT, content="Assist3"),
|
||||
]
|
||||
|
||||
async for _, msg in mock_channel.invoke(mock_agent):
|
||||
pass
|
||||
|
||||
# let's define expected roles from that final structure:
|
||||
expected_roles = [
|
||||
AuthorRole.USER,
|
||||
AuthorRole.ASSISTANT, # placeholder
|
||||
AuthorRole.USER,
|
||||
AuthorRole.ASSISTANT,
|
||||
AuthorRole.USER, # placeholder
|
||||
AuthorRole.ASSISTANT,
|
||||
AuthorRole.USER,
|
||||
AuthorRole.ASSISTANT, # placeholder
|
||||
AuthorRole.USER,
|
||||
AuthorRole.ASSISTANT,
|
||||
AuthorRole.USER, # placeholder
|
||||
]
|
||||
expected_contents = [
|
||||
"User1",
|
||||
BedrockAgentChannel.MESSAGE_PLACEHOLDER,
|
||||
"User2",
|
||||
"Assist1",
|
||||
BedrockAgentChannel.MESSAGE_PLACEHOLDER,
|
||||
"Assist2",
|
||||
"User3",
|
||||
BedrockAgentChannel.MESSAGE_PLACEHOLDER,
|
||||
"User4",
|
||||
"Assist3",
|
||||
BedrockAgentChannel.MESSAGE_PLACEHOLDER,
|
||||
]
|
||||
|
||||
assert len(mock_channel.messages) == len(expected_roles)
|
||||
for i, (msg, exp_role, exp_content) in enumerate(zip(mock_channel.messages, expected_roles, expected_contents)):
|
||||
assert msg.role == exp_role, f"Role mismatch at index {i}. Got {msg.role}, expected {exp_role}"
|
||||
assert msg.content == exp_content, f"Content mismatch at index {i}. Got {msg.content}, expected {exp_content}"
|
||||
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.bedrock.models.bedrock_agent_event_type import BedrockAgentEventType
|
||||
|
||||
|
||||
def test_bedrock_agent_event_type_values():
|
||||
"""Test case to verify the values of BedrockAgentEventType enum."""
|
||||
assert BedrockAgentEventType.CHUNK.value == "chunk"
|
||||
assert BedrockAgentEventType.TRACE.value == "trace"
|
||||
assert BedrockAgentEventType.RETURN_CONTROL.value == "returnControl"
|
||||
assert BedrockAgentEventType.FILES.value == "files"
|
||||
|
||||
|
||||
def test_bedrock_agent_event_type_enum():
|
||||
"""Test case to verify the type of BedrockAgentEventType enum members."""
|
||||
assert isinstance(BedrockAgentEventType.CHUNK, BedrockAgentEventType)
|
||||
assert isinstance(BedrockAgentEventType.TRACE, BedrockAgentEventType)
|
||||
assert isinstance(BedrockAgentEventType.RETURN_CONTROL, BedrockAgentEventType)
|
||||
assert isinstance(BedrockAgentEventType.FILES, BedrockAgentEventType)
|
||||
|
||||
|
||||
def test_bedrock_agent_event_type_invalid():
|
||||
"""Test case to verify error handling for invalid BedrockAgentEventType value."""
|
||||
with pytest.raises(ValueError):
|
||||
BedrockAgentEventType("invalid_value")
|
||||
@@ -0,0 +1,67 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.agents.bedrock.models.bedrock_agent_model import BedrockAgentModel
|
||||
|
||||
|
||||
def test_bedrock_agent_model_valid():
|
||||
"""Test case to verify the BedrockAgentModel with valid data."""
|
||||
model = BedrockAgentModel(
|
||||
agentId="test_id",
|
||||
agentName="test_name",
|
||||
agentVersion="1.0",
|
||||
foundationModel="test_model",
|
||||
agentStatus="CREATING",
|
||||
)
|
||||
assert model.agent_id == "test_id"
|
||||
assert model.agent_name == "test_name"
|
||||
assert model.agent_version == "1.0"
|
||||
assert model.foundation_model == "test_model"
|
||||
assert model.agent_status == "CREATING"
|
||||
|
||||
|
||||
def test_bedrock_agent_model_missing_agent_id():
|
||||
"""Test case to verify the BedrockAgentModel with missing agentId."""
|
||||
model = BedrockAgentModel(
|
||||
agentName="test_name",
|
||||
agentVersion="1.0",
|
||||
foundationModel="test_model",
|
||||
agentStatus="CREATING",
|
||||
)
|
||||
assert model.agent_id is None
|
||||
assert model.agent_name == "test_name"
|
||||
assert model.agent_version == "1.0"
|
||||
assert model.foundation_model == "test_model"
|
||||
assert model.agent_status == "CREATING"
|
||||
|
||||
|
||||
def test_bedrock_agent_model_missing_agent_name():
|
||||
"""Test case to verify the BedrockAgentModel with missing agentName."""
|
||||
model = BedrockAgentModel(
|
||||
agentId="test_id",
|
||||
agentVersion="1.0",
|
||||
foundationModel="test_model",
|
||||
agentStatus="CREATING",
|
||||
)
|
||||
assert model.agent_id == "test_id"
|
||||
assert model.agent_name is None
|
||||
assert model.agent_version == "1.0"
|
||||
assert model.foundation_model == "test_model"
|
||||
assert model.agent_status == "CREATING"
|
||||
|
||||
|
||||
def test_bedrock_agent_model_extra_field():
|
||||
"""Test case to verify the BedrockAgentModel with an extra field."""
|
||||
model = BedrockAgentModel(
|
||||
agentId="test_id",
|
||||
agentName="test_name",
|
||||
agentVersion="1.0",
|
||||
foundationModel="test_model",
|
||||
agentStatus="CREATING",
|
||||
extraField="extra_value",
|
||||
)
|
||||
assert model.agent_id == "test_id"
|
||||
assert model.agent_name == "test_name"
|
||||
assert model.agent_version == "1.0"
|
||||
assert model.foundation_model == "test_model"
|
||||
assert model.agent_status == "CREATING"
|
||||
assert model.extraField == "extra_value"
|
||||
@@ -0,0 +1,28 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from semantic_kernel.agents.bedrock.bedrock_agent_settings import BedrockAgentSettings
|
||||
|
||||
|
||||
def test_bedrock_agent_settings_from_env_vars(bedrock_agent_unit_test_env):
|
||||
"""Test loading BedrockAgentSettings from environment variables."""
|
||||
settings = BedrockAgentSettings(env_file_path="fake_path")
|
||||
|
||||
assert settings.agent_resource_role_arn == bedrock_agent_unit_test_env["BEDROCK_AGENT_AGENT_RESOURCE_ROLE_ARN"]
|
||||
assert settings.foundation_model == bedrock_agent_unit_test_env["BEDROCK_AGENT_FOUNDATION_MODEL"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exclude_list",
|
||||
[
|
||||
["BEDROCK_AGENT_AGENT_RESOURCE_ROLE_ARN"],
|
||||
["BEDROCK_AGENT_FOUNDATION_MODEL"],
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
def test_bedrock_agent_settings_from_env_vars_missing_required(bedrock_agent_unit_test_env):
|
||||
"""Test loading BedrockAgentSettings from environment variables with missing required fields."""
|
||||
with pytest.raises(ValidationError):
|
||||
BedrockAgentSettings(env_file_path="fake_path")
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.bedrock.models.bedrock_agent_status import BedrockAgentStatus
|
||||
|
||||
|
||||
def test_bedrock_agent_status_values():
|
||||
"""Test case to verify the values of BedrockAgentStatus enum."""
|
||||
assert BedrockAgentStatus.CREATING == "CREATING"
|
||||
assert BedrockAgentStatus.PREPARING == "PREPARING"
|
||||
assert BedrockAgentStatus.PREPARED == "PREPARED"
|
||||
assert BedrockAgentStatus.NOT_PREPARED == "NOT_PREPARED"
|
||||
assert BedrockAgentStatus.DELETING == "DELETING"
|
||||
assert BedrockAgentStatus.FAILED == "FAILED"
|
||||
assert BedrockAgentStatus.VERSIONING == "VERSIONING"
|
||||
assert BedrockAgentStatus.UPDATING == "UPDATING"
|
||||
|
||||
|
||||
def test_bedrock_agent_status_invalid_value():
|
||||
"""Test case to verify error handling for invalid BedrockAgentStatus value."""
|
||||
with pytest.raises(ValueError):
|
||||
BedrockAgentStatus("INVALID_STATUS")
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, create_autospec
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.kernel import Kernel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kernel_with_ai_service():
|
||||
kernel = create_autospec(Kernel)
|
||||
mock_ai_service_client = create_autospec(ChatCompletionClientBase)
|
||||
mock_prompt_execution_settings = create_autospec(PromptExecutionSettings)
|
||||
mock_prompt_execution_settings.function_choice_behavior = None
|
||||
kernel.select_ai_service.return_value = (mock_ai_service_client, mock_prompt_execution_settings)
|
||||
mock_ai_service_client.get_chat_message_contents = AsyncMock(
|
||||
return_value=[ChatMessageContent(role=AuthorRole.SYSTEM, content="Processed Message")]
|
||||
)
|
||||
kernel.plugins = {} # Ensure plugins dict is initialized to avoid AttributeError during tests
|
||||
|
||||
return kernel, mock_ai_service_client
|
||||
@@ -0,0 +1,471 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from types import MethodType
|
||||
from unittest.mock import AsyncMock, create_autospec, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from semantic_kernel.agents import ChatCompletionAgent
|
||||
from semantic_kernel.agents.channels.chat_history_channel import ChatHistoryChannel
|
||||
from semantic_kernel.agents.chat_completion.chat_completion_agent import ChatHistoryAgentThread
|
||||
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
|
||||
from semantic_kernel.connectors.ai.open_ai.services.azure_chat_completion import AzureChatCompletion
|
||||
from semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion import OpenAIChatCompletion
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
from semantic_kernel.contents.function_result_content import FunctionResultContent
|
||||
from semantic_kernel.contents.history_reducer.chat_history_truncation_reducer import ChatHistoryTruncationReducer
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.streaming_text_content import StreamingTextContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions import KernelServiceNotFoundError
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentInvokeException
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.kernel import Kernel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_streaming_chat_completion_response() -> Callable[..., AsyncGenerator[list[ChatMessageContent], None]]:
|
||||
async def mock_response(
|
||||
chat_history: ChatHistory,
|
||||
settings: PromptExecutionSettings,
|
||||
kernel: Kernel,
|
||||
arguments: KernelArguments,
|
||||
) -> AsyncGenerator[list[ChatMessageContent], None]:
|
||||
content1 = ChatMessageContent(role=AuthorRole.SYSTEM, content="Processed Message 1")
|
||||
content2 = ChatMessageContent(role=AuthorRole.TOOL, content="Processed Message 2")
|
||||
chat_history.messages.append(content1)
|
||||
chat_history.messages.append(content2)
|
||||
yield [content1]
|
||||
yield [content2]
|
||||
|
||||
return mock_response
|
||||
|
||||
|
||||
async def test_initialization():
|
||||
agent = ChatCompletionAgent(
|
||||
name="TestAgent",
|
||||
id="test_id",
|
||||
description="Test Description",
|
||||
instructions="Test Instructions",
|
||||
)
|
||||
|
||||
assert agent.name == "TestAgent"
|
||||
assert agent.id == "test_id"
|
||||
assert agent.description == "Test Description"
|
||||
assert agent.instructions == "Test Instructions"
|
||||
|
||||
|
||||
async def test_initialization_invalid_name_throws():
|
||||
with pytest.raises(ValidationError):
|
||||
_ = ChatCompletionAgent(
|
||||
name="Test Agent",
|
||||
id="test_id",
|
||||
description="Test Description",
|
||||
instructions="Test Instructions",
|
||||
)
|
||||
|
||||
|
||||
def test_initialization_with_kernel(kernel: Kernel):
|
||||
agent = ChatCompletionAgent(
|
||||
kernel=kernel,
|
||||
name="TestAgent",
|
||||
id="test_id",
|
||||
description="Test Description",
|
||||
instructions="Test Instructions",
|
||||
)
|
||||
|
||||
assert kernel == agent.kernel
|
||||
assert agent.name == "TestAgent"
|
||||
assert agent.id == "test_id"
|
||||
assert agent.description == "Test Description"
|
||||
assert agent.instructions == "Test Instructions"
|
||||
|
||||
|
||||
def test_initialization_with_kernel_and_service(kernel: Kernel, azure_openai_unit_test_env, openai_unit_test_env):
|
||||
kernel.add_service(AzureChatCompletion(service_id="test_azure"))
|
||||
agent = ChatCompletionAgent(
|
||||
service=OpenAIChatCompletion(),
|
||||
kernel=kernel,
|
||||
name="TestAgent",
|
||||
id="test_id",
|
||||
description="Test Description",
|
||||
instructions="Test Instructions",
|
||||
)
|
||||
|
||||
assert kernel == agent.kernel
|
||||
assert len(kernel.services) == 2
|
||||
assert agent.name == "TestAgent"
|
||||
assert agent.id == "test_id"
|
||||
assert agent.description == "Test Description"
|
||||
assert agent.instructions == "Test Instructions"
|
||||
|
||||
|
||||
def test_initialization_with_plugins_via_constructor(custom_plugin_class):
|
||||
agent = ChatCompletionAgent(
|
||||
name="TestAgent",
|
||||
id="test_id",
|
||||
description="Test Description",
|
||||
instructions="Test Instructions",
|
||||
plugins=[custom_plugin_class()],
|
||||
)
|
||||
|
||||
assert agent.name == "TestAgent"
|
||||
assert agent.id == "test_id"
|
||||
assert agent.description == "Test Description"
|
||||
assert agent.instructions == "Test Instructions"
|
||||
assert agent.kernel.plugins is not None
|
||||
assert len(agent.kernel.plugins) == 1
|
||||
|
||||
|
||||
def test_initialization_with_service_via_constructor(openai_unit_test_env):
|
||||
agent = ChatCompletionAgent(
|
||||
name="TestAgent",
|
||||
id="test_id",
|
||||
description="Test Description",
|
||||
instructions="Test Instructions",
|
||||
service=OpenAIChatCompletion(),
|
||||
)
|
||||
|
||||
assert agent.name == "TestAgent"
|
||||
assert agent.id == "test_id"
|
||||
assert agent.description == "Test Description"
|
||||
assert agent.instructions == "Test Instructions"
|
||||
assert agent.service is not None
|
||||
assert agent.kernel.services["test_chat_model_id"] == agent.service
|
||||
|
||||
|
||||
def test_initialize_chat_history_agent_thread_with_id():
|
||||
thread = ChatHistoryAgentThread(thread_id="test_thread_id")
|
||||
assert thread is not None
|
||||
assert thread.id == "test_thread_id"
|
||||
|
||||
|
||||
def test_initialize_with_base_chat_history():
|
||||
base_history = ChatHistory()
|
||||
thread = ChatHistoryAgentThread(chat_history=base_history, thread_id="base_test_thread")
|
||||
assert thread is not None
|
||||
assert thread.id == "base_test_thread"
|
||||
assert isinstance(thread._chat_history, ChatHistory)
|
||||
assert not isinstance(thread._chat_history, ChatHistoryTruncationReducer)
|
||||
|
||||
|
||||
def test_initialize_with_reducer_chat_history():
|
||||
reducer = ChatHistoryTruncationReducer(
|
||||
service=AsyncMock(spec=ChatCompletionClientBase), target_count=10, threshold_count=2
|
||||
)
|
||||
thread = ChatHistoryAgentThread(chat_history=reducer, thread_id="reducer_test_thread")
|
||||
assert thread is not None
|
||||
assert thread.id == "reducer_test_thread"
|
||||
assert isinstance(thread._chat_history, ChatHistoryTruncationReducer)
|
||||
|
||||
|
||||
async def test_get_response(kernel_with_ai_service: tuple[Kernel, ChatCompletionClientBase]):
|
||||
kernel, _ = kernel_with_ai_service
|
||||
agent = ChatCompletionAgent(
|
||||
kernel=kernel,
|
||||
name="TestAgent",
|
||||
instructions="Test Instructions",
|
||||
)
|
||||
|
||||
thread = ChatHistoryAgentThread()
|
||||
|
||||
response = await agent.get_response(messages="test", thread=thread)
|
||||
|
||||
assert response.message.content == "Processed Message"
|
||||
assert response.thread is not None
|
||||
|
||||
|
||||
async def test_get_response_exception(kernel_with_ai_service: tuple[Kernel, ChatCompletionClientBase]):
|
||||
kernel, mock_ai_service_client = kernel_with_ai_service
|
||||
mock_ai_service_client.get_chat_message_contents = AsyncMock(return_value=[])
|
||||
agent = ChatCompletionAgent(
|
||||
kernel=kernel,
|
||||
name="TestAgent",
|
||||
instructions="Test Instructions",
|
||||
)
|
||||
|
||||
thread = ChatHistoryAgentThread()
|
||||
|
||||
with pytest.raises(AgentInvokeException):
|
||||
await agent.get_response(messages="test", thread=thread)
|
||||
|
||||
|
||||
async def test_invoke(kernel_with_ai_service: tuple[Kernel, ChatCompletionClientBase]):
|
||||
kernel, _ = kernel_with_ai_service
|
||||
agent = ChatCompletionAgent(
|
||||
kernel=kernel,
|
||||
name="TestAgent",
|
||||
instructions="Test Instructions",
|
||||
)
|
||||
|
||||
thread = ChatHistoryAgentThread()
|
||||
|
||||
messages = [message async for message in agent.invoke(messages="test", thread=thread)]
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0].message.content == "Processed Message"
|
||||
|
||||
|
||||
async def test_invoke_emits_tool_call_then_result_then_text(kernel_with_ai_service):
|
||||
kernel, chat_client = kernel_with_ai_service
|
||||
agent = ChatCompletionAgent(kernel=kernel, name="TestAgent")
|
||||
thread = ChatHistoryAgentThread()
|
||||
|
||||
call_msg = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
items=[FunctionCallContent(id="test-id", name="get_specials", arguments="{}")],
|
||||
)
|
||||
result_msg = ChatMessageContent(
|
||||
role=AuthorRole.TOOL,
|
||||
items=[FunctionResultContent(id="test-id", name="get_specials", result="Clam Chowder")],
|
||||
)
|
||||
|
||||
final_msg = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
content="Clam Chowder is today's soup.",
|
||||
)
|
||||
|
||||
chat_client.get_chat_message_contents = AsyncMock(return_value=[final_msg])
|
||||
|
||||
async def fake_drain(self, *_args, **_kwargs):
|
||||
if not fake_drain.called:
|
||||
fake_drain.called = True
|
||||
return [call_msg, result_msg]
|
||||
return []
|
||||
|
||||
fake_drain.called = False
|
||||
|
||||
with patch.object(ChatCompletionAgent, "_drain_mutated_messages", new=AsyncMock(side_effect=fake_drain)):
|
||||
cb_messages: list[ChatMessageContent] = []
|
||||
|
||||
async def on_msg(m: ChatMessageContent):
|
||||
cb_messages.append(m)
|
||||
|
||||
messages = [
|
||||
m
|
||||
async for m in agent.invoke(
|
||||
messages="What's the special soup?", thread=thread, on_intermediate_message=on_msg
|
||||
)
|
||||
]
|
||||
|
||||
assert [type(m.items[0]) for m in cb_messages] == [
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
]
|
||||
assert len(messages) == 1
|
||||
assert isinstance(messages[0].message, ChatMessageContent)
|
||||
assert messages[0].message.content.startswith("Clam Chowder")
|
||||
assert messages[0].message.name == agent.name
|
||||
|
||||
|
||||
async def test_invoke_tool_call_not_added(kernel_with_ai_service: tuple[Kernel, ChatCompletionClientBase]):
|
||||
kernel, mock_ai_service_client = kernel_with_ai_service
|
||||
agent = ChatCompletionAgent(
|
||||
kernel=kernel,
|
||||
name="TestAgent",
|
||||
)
|
||||
|
||||
thread = ChatHistoryAgentThread()
|
||||
|
||||
async def mock_get_chat_message_contents(
|
||||
chat_history: ChatHistory,
|
||||
settings: PromptExecutionSettings,
|
||||
kernel: Kernel,
|
||||
arguments: KernelArguments,
|
||||
):
|
||||
responses = [
|
||||
ChatMessageContent(
|
||||
role=AuthorRole.TOOL,
|
||||
items=[FunctionResultContent(result="Tool Call Result")],
|
||||
),
|
||||
]
|
||||
chat_history.messages.extend(responses)
|
||||
return responses
|
||||
|
||||
mock_ai_service_client.get_chat_message_contents = AsyncMock(side_effect=mock_get_chat_message_contents)
|
||||
|
||||
messages = [message async for message in agent.invoke(messages="test", thread=thread)]
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0].message.items[0].result == "Tool Call Result"
|
||||
assert messages[0].message.role == AuthorRole.TOOL
|
||||
assert messages[0].message.name == "TestAgent"
|
||||
|
||||
thread: ChatHistoryAgentThread = messages[-1].thread
|
||||
thread_messages = [message async for message in thread.get_messages()]
|
||||
|
||||
assert len(thread_messages) == 2
|
||||
assert thread_messages[0].content == "test"
|
||||
assert thread_messages[1].items[0].result == "Tool Call Result"
|
||||
assert thread_messages[1].name == "TestAgent"
|
||||
assert thread_messages[1].role == AuthorRole.TOOL
|
||||
|
||||
|
||||
async def test_invoke_no_service_throws(kernel: Kernel):
|
||||
agent = ChatCompletionAgent(kernel=kernel, name="TestAgent")
|
||||
|
||||
with pytest.raises(KernelServiceNotFoundError):
|
||||
async for _ in agent.invoke(messages="test", thread=None):
|
||||
pass
|
||||
|
||||
|
||||
async def test_invoke_stream(kernel_with_ai_service: tuple[Kernel, ChatCompletionClientBase]):
|
||||
kernel, _ = kernel_with_ai_service
|
||||
agent = ChatCompletionAgent(kernel=kernel, name="TestAgent")
|
||||
|
||||
thread = ChatHistoryAgentThread()
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.connectors.ai.chat_completion_client_base.ChatCompletionClientBase.get_streaming_chat_message_contents",
|
||||
return_value=AsyncMock(),
|
||||
) as mock:
|
||||
mock.return_value.__aiter__.return_value = [
|
||||
[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]
|
||||
]
|
||||
|
||||
async for response in agent.invoke_stream(messages="Initial Message", thread=thread):
|
||||
assert response.message.role == AuthorRole.USER
|
||||
assert response.message.content == "Initial Message"
|
||||
|
||||
|
||||
async def test_invoke_stream_emits_tool_call_then_result_then_text(kernel_with_ai_service):
|
||||
kernel, chat_client = kernel_with_ai_service
|
||||
agent = ChatCompletionAgent(kernel=kernel, name="TestAgent")
|
||||
thread = ChatHistoryAgentThread()
|
||||
|
||||
call_msg = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
items=[FunctionCallContent(id="test-id", name="get_specials", arguments="{}")],
|
||||
)
|
||||
result_msg = ChatMessageContent(
|
||||
role=AuthorRole.TOOL,
|
||||
items=[FunctionResultContent(id="test-id", name="get_specials", result="Clam Chowder")],
|
||||
)
|
||||
|
||||
text_msg = StreamingChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
content="Clam Chowder is today's soup.",
|
||||
items=[StreamingTextContent(text="Clam Chowder is today's soup.", choice_index=0)],
|
||||
choice_index=0,
|
||||
)
|
||||
|
||||
async def fake_stream(*_args, **_kwargs):
|
||||
yield [StreamingChatMessageContent(role=AuthorRole.ASSISTANT, content="", items=[], choice_index=0)]
|
||||
yield [text_msg]
|
||||
|
||||
chat_client.get_streaming_chat_message_contents = MethodType(fake_stream, chat_client)
|
||||
|
||||
async def fake_drain(self, *_args, **_kwargs):
|
||||
if not fake_drain.called:
|
||||
fake_drain.called = True
|
||||
return [call_msg, result_msg]
|
||||
return []
|
||||
|
||||
fake_drain.called = False
|
||||
|
||||
with patch.object(ChatCompletionAgent, "_drain_mutated_messages", new=AsyncMock(side_effect=fake_drain)):
|
||||
cb_messages: list[ChatMessageContent] = []
|
||||
|
||||
async def on_msg(m: ChatMessageContent):
|
||||
cb_messages.append(m)
|
||||
|
||||
yielded_text: list[StreamingChatMessageContent] = []
|
||||
async for resp in agent.invoke_stream(
|
||||
messages="What's the special soup?",
|
||||
thread=thread,
|
||||
on_intermediate_message=on_msg,
|
||||
):
|
||||
yielded_text.append(resp.message)
|
||||
|
||||
assert [type(m.items[0]) for m in cb_messages] == [
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
]
|
||||
assert len(yielded_text) == 1
|
||||
assert isinstance(yielded_text[0], StreamingChatMessageContent)
|
||||
assert yielded_text[0].content.startswith("Clam Chowder")
|
||||
assert yielded_text[0].name == agent.name
|
||||
|
||||
|
||||
async def test_invoke_stream_tool_call_added(
|
||||
kernel_with_ai_service: tuple[Kernel, ChatCompletionClientBase],
|
||||
mock_streaming_chat_completion_response,
|
||||
):
|
||||
kernel, mock_ai_service_client = kernel_with_ai_service
|
||||
agent = ChatCompletionAgent(kernel=kernel, name="TestAgent")
|
||||
|
||||
thread = ChatHistoryAgentThread()
|
||||
|
||||
mock_ai_service_client.get_streaming_chat_message_contents = mock_streaming_chat_completion_response
|
||||
|
||||
async for response in agent.invoke_stream(messages="Initial Message", thread=thread):
|
||||
assert response.message.role in [AuthorRole.SYSTEM, AuthorRole.TOOL]
|
||||
assert response.message.content in ["Processed Message 1", "Processed Message 2"]
|
||||
|
||||
|
||||
async def test_invoke_stream_no_service_throws(kernel: Kernel):
|
||||
agent = ChatCompletionAgent(kernel=kernel, name="TestAgent")
|
||||
|
||||
thread = ChatHistoryAgentThread()
|
||||
|
||||
with pytest.raises(KernelServiceNotFoundError):
|
||||
async for _ in agent.invoke_stream(messages="test", thread=thread):
|
||||
pass
|
||||
|
||||
|
||||
def test_get_channel_keys():
|
||||
agent = ChatCompletionAgent()
|
||||
keys = agent.get_channel_keys()
|
||||
|
||||
for key in keys:
|
||||
assert isinstance(key, str)
|
||||
|
||||
|
||||
async def test_create_channel():
|
||||
agent = ChatCompletionAgent()
|
||||
channel = await agent.create_channel()
|
||||
|
||||
assert isinstance(channel, ChatHistoryChannel)
|
||||
|
||||
|
||||
async def test_prepare_agent_chat_history_with_formatted_instructions():
|
||||
agent = ChatCompletionAgent(
|
||||
name="TestAgent", id="test_id", description="Test Description", instructions="Test Instructions"
|
||||
)
|
||||
with patch.object(
|
||||
ChatCompletionAgent, "format_instructions", new=AsyncMock(return_value="Formatted instructions for testing")
|
||||
) as mock_format_instructions:
|
||||
dummy_kernel = create_autospec(Kernel)
|
||||
dummy_args = KernelArguments(param="value")
|
||||
user_message = ChatMessageContent(role=AuthorRole.USER, content="User message")
|
||||
history = ChatHistory(messages=[user_message])
|
||||
result_history = await agent._prepare_agent_chat_history(history, dummy_kernel, dummy_args)
|
||||
mock_format_instructions.assert_awaited_once_with(dummy_kernel, dummy_args)
|
||||
assert len(result_history.messages) == 2
|
||||
system_message = result_history.messages[0]
|
||||
assert system_message.role == AuthorRole.SYSTEM
|
||||
assert system_message.content == "Formatted instructions for testing"
|
||||
assert system_message.name == agent.name
|
||||
assert result_history.messages[1] == user_message
|
||||
|
||||
|
||||
async def test_prepare_agent_chat_history_without_formatted_instructions():
|
||||
agent = ChatCompletionAgent(
|
||||
name="TestAgent", id="test_id", description="Test Description", instructions="Test Instructions"
|
||||
)
|
||||
with patch.object(
|
||||
ChatCompletionAgent, "format_instructions", new=AsyncMock(return_value=None)
|
||||
) as mock_format_instructions:
|
||||
dummy_kernel = create_autospec(Kernel)
|
||||
dummy_args = KernelArguments(param="value")
|
||||
user_message = ChatMessageContent(role=AuthorRole.USER, content="User message")
|
||||
history = ChatHistory(messages=[user_message])
|
||||
result_history = await agent._prepare_agent_chat_history(history, dummy_kernel, dummy_args)
|
||||
mock_format_instructions.assert_awaited_once_with(dummy_kernel, dummy_args)
|
||||
assert len(result_history.messages) == 1
|
||||
assert result_history.messages[0] == user_message
|
||||
@@ -0,0 +1,252 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.agent import AgentResponseItem
|
||||
from semantic_kernel.agents.channels.chat_history_channel import ChatHistoryChannel
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.file_reference_content import FileReferenceContent
|
||||
from semantic_kernel.contents.function_result_content import FunctionResultContent
|
||||
from semantic_kernel.contents.streaming_file_reference_content import StreamingFileReferenceContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chat_history_channel() -> ChatHistoryChannel:
|
||||
from semantic_kernel.agents.chat_completion.chat_completion_agent import ChatHistoryAgentThread
|
||||
|
||||
ChatHistoryChannel.model_rebuild()
|
||||
|
||||
thread = ChatHistoryAgentThread()
|
||||
|
||||
return ChatHistoryChannel(thread=thread)
|
||||
|
||||
|
||||
class MockChatHistoryHandler:
|
||||
"""Mock agent to test chat history handling"""
|
||||
|
||||
async def invoke(self, history: list[ChatMessageContent]) -> AsyncIterable[ChatMessageContent]:
|
||||
for message in history:
|
||||
yield ChatMessageContent(role=AuthorRole.SYSTEM, content=f"Processed: {message.content}")
|
||||
|
||||
async def invoke_stream(self, history: list[ChatMessageContent]) -> AsyncIterable[ChatMessageContent]:
|
||||
for message in history:
|
||||
yield ChatMessageContent(role=AuthorRole.SYSTEM, content=f"Processed: {message.content}")
|
||||
|
||||
async def reduce_history(self, history: list[ChatMessageContent]) -> list[ChatMessageContent]:
|
||||
return history
|
||||
|
||||
|
||||
class MockNonChatHistoryHandler:
|
||||
"""Mock agent to test incorrect instance handling."""
|
||||
|
||||
id: str = "mock_non_chat_history_handler"
|
||||
|
||||
|
||||
class AsyncIterableMock:
|
||||
def __init__(self, async_gen):
|
||||
self.async_gen = async_gen
|
||||
|
||||
def __aiter__(self):
|
||||
return self.async_gen()
|
||||
|
||||
|
||||
async def test_invoke(chat_history_channel):
|
||||
channel = chat_history_channel
|
||||
agent = AsyncMock(spec=MockChatHistoryHandler)
|
||||
|
||||
async def mock_invoke(history: list[ChatMessageContent]):
|
||||
for message in history:
|
||||
yield AgentResponseItem(
|
||||
message=ChatMessageContent(role=AuthorRole.SYSTEM, content=f"Processed: {message.content}"),
|
||||
thread=channel.thread,
|
||||
)
|
||||
|
||||
agent.invoke.return_value = AsyncIterableMock(
|
||||
lambda: mock_invoke([ChatMessageContent(role=AuthorRole.USER, content="Initial message")])
|
||||
)
|
||||
|
||||
initial_message = ChatMessageContent(role=AuthorRole.USER, content="Initial message")
|
||||
channel.messages.append(initial_message)
|
||||
|
||||
received_messages = []
|
||||
async for is_visible, message in channel.invoke(agent, thread=channel.thread):
|
||||
received_messages.append(message)
|
||||
assert is_visible
|
||||
|
||||
assert len(received_messages) == 1
|
||||
assert "Processed: Initial message" in received_messages[0].content
|
||||
|
||||
|
||||
async def test_invoke_stream(chat_history_channel):
|
||||
channel = chat_history_channel
|
||||
agent = AsyncMock(spec=MockChatHistoryHandler)
|
||||
|
||||
async def mock_invoke(history: list[ChatMessageContent]):
|
||||
for message in history:
|
||||
msg = AgentResponseItem(
|
||||
message=ChatMessageContent(role=AuthorRole.SYSTEM, content=f"Processed: {message.content}"),
|
||||
thread=channel.thread,
|
||||
)
|
||||
yield msg
|
||||
channel.add_message(msg.message)
|
||||
|
||||
agent.invoke_stream.return_value = AsyncIterableMock(
|
||||
lambda: mock_invoke([ChatMessageContent(role=AuthorRole.USER, content="Initial message")])
|
||||
)
|
||||
|
||||
initial_message = ChatMessageContent(role=AuthorRole.USER, content="Initial message")
|
||||
channel.messages.append(initial_message)
|
||||
|
||||
received_messages = []
|
||||
async for message in channel.invoke_stream(agent, thread=channel.thread, messages=received_messages):
|
||||
assert message is not None
|
||||
|
||||
assert len(received_messages) == 1
|
||||
assert "Processed: Initial message" in received_messages[0].content
|
||||
|
||||
|
||||
async def test_invoke_leftover_in_queue(chat_history_channel):
|
||||
channel = chat_history_channel
|
||||
agent = AsyncMock(spec=MockChatHistoryHandler)
|
||||
|
||||
async def mock_invoke(history: list[ChatMessageContent]):
|
||||
for message in history:
|
||||
yield AgentResponseItem(
|
||||
message=ChatMessageContent(role=AuthorRole.SYSTEM, content=f"Processed: {message.content}"),
|
||||
thread=channel.thread,
|
||||
)
|
||||
yield AgentResponseItem(
|
||||
message=ChatMessageContent(
|
||||
role=AuthorRole.SYSTEM,
|
||||
content="Final message",
|
||||
items=[FunctionResultContent(id="test_id", result="test")],
|
||||
),
|
||||
thread=channel.thread,
|
||||
)
|
||||
|
||||
agent.invoke.return_value = AsyncIterableMock(
|
||||
lambda: mock_invoke([
|
||||
ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
content="Initial message",
|
||||
items=[FunctionResultContent(id="test_id", result="test")],
|
||||
)
|
||||
])
|
||||
)
|
||||
|
||||
initial_message = ChatMessageContent(role=AuthorRole.USER, content="Initial message")
|
||||
channel.messages.append(initial_message)
|
||||
|
||||
received_messages = []
|
||||
async for is_visible, message in channel.invoke(agent, thread=channel.thread):
|
||||
received_messages.append(message)
|
||||
assert is_visible
|
||||
if len(received_messages) >= 3:
|
||||
break
|
||||
|
||||
assert len(received_messages) == 3
|
||||
assert "Processed: Initial message" in received_messages[0].content
|
||||
assert "Final message" in received_messages[2].content
|
||||
assert received_messages[2].items[0].id == "test_id"
|
||||
|
||||
|
||||
async def test_receive(chat_history_channel):
|
||||
channel = chat_history_channel
|
||||
history = [
|
||||
ChatMessageContent(role=AuthorRole.SYSTEM, content="test message 1"),
|
||||
ChatMessageContent(role=AuthorRole.USER, content="test message 2"),
|
||||
]
|
||||
|
||||
await channel.receive(history)
|
||||
|
||||
assert len(channel.messages) == 2
|
||||
assert channel.messages[0].content == "test message 1"
|
||||
assert channel.messages[0].role == AuthorRole.SYSTEM
|
||||
assert channel.messages[1].content == "test message 2"
|
||||
assert channel.messages[1].role == AuthorRole.USER
|
||||
|
||||
|
||||
async def test_get_history(chat_history_channel):
|
||||
channel = chat_history_channel
|
||||
history = [
|
||||
ChatMessageContent(role=AuthorRole.SYSTEM, content="test message 1"),
|
||||
ChatMessageContent(role=AuthorRole.USER, content="test message 2"),
|
||||
]
|
||||
channel.messages.extend(history)
|
||||
|
||||
messages = [message async for message in channel.get_history()]
|
||||
|
||||
assert len(messages) == 2
|
||||
assert messages[0].content == "test message 2"
|
||||
assert messages[0].role == AuthorRole.USER
|
||||
assert messages[1].content == "test message 1"
|
||||
assert messages[1].role == AuthorRole.SYSTEM
|
||||
|
||||
|
||||
async def test_reset_history(chat_history_channel):
|
||||
channel = chat_history_channel
|
||||
history = [
|
||||
ChatMessageContent(role=AuthorRole.SYSTEM, content="test message 1"),
|
||||
ChatMessageContent(role=AuthorRole.USER, content="test message 2"),
|
||||
]
|
||||
channel.messages.extend(history)
|
||||
|
||||
messages = [message async for message in channel.get_history()]
|
||||
|
||||
assert len(messages) == 2
|
||||
assert messages[0].content == "test message 2"
|
||||
assert messages[0].role == AuthorRole.USER
|
||||
assert messages[1].content == "test message 1"
|
||||
assert messages[1].role == AuthorRole.SYSTEM
|
||||
|
||||
await channel.reset()
|
||||
|
||||
assert len(channel.messages) == 0
|
||||
|
||||
|
||||
async def test_receive_skips_file_references(chat_history_channel):
|
||||
channel = chat_history_channel
|
||||
|
||||
file_ref_item = FileReferenceContent()
|
||||
streaming_file_ref_item = StreamingFileReferenceContent()
|
||||
normal_item_1 = FunctionResultContent(id="test_id", result="normal content 1")
|
||||
normal_item_2 = FunctionResultContent(id="test_id_2", result="normal content 2")
|
||||
|
||||
msg_with_file_only = ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
content="Normal message set as TextContent",
|
||||
items=[file_ref_item],
|
||||
)
|
||||
|
||||
msg_with_mixed = ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
content="Mixed content message",
|
||||
items=[streaming_file_ref_item, normal_item_1],
|
||||
)
|
||||
|
||||
msg_with_normal = ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
content="Normal message",
|
||||
items=[normal_item_2],
|
||||
)
|
||||
|
||||
history = [msg_with_file_only, msg_with_mixed, msg_with_normal]
|
||||
await channel.receive(history)
|
||||
|
||||
assert len(channel.messages) == 3
|
||||
|
||||
assert channel.messages[0].content == "Normal message set as TextContent"
|
||||
assert len(channel.messages[0].items) == 1
|
||||
|
||||
assert channel.messages[1].content == "Mixed content message"
|
||||
assert len(channel.messages[0].items) == 1
|
||||
assert channel.messages[1].items[0].result == "normal content 1"
|
||||
|
||||
assert channel.messages[2].content == "Normal message"
|
||||
assert len(channel.messages[2].items) == 2
|
||||
assert channel.messages[2].items[0].result == "normal content 2"
|
||||
assert channel.messages[2].items[1].text == "Normal message"
|
||||
@@ -0,0 +1,359 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from types import SimpleNamespace
|
||||
from typing import TypeVar
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr, ValidationError
|
||||
|
||||
try:
|
||||
import microsoft_agents.copilotstudio.client # type: ignore # noqa: F401
|
||||
|
||||
copilot_installed = True
|
||||
except ImportError:
|
||||
copilot_installed = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(not copilot_installed, reason="`copilotstudio.client` is not installed")
|
||||
|
||||
if copilot_installed:
|
||||
import semantic_kernel.agents.copilot_studio.copilot_studio_agent as csa_mod
|
||||
from semantic_kernel.agents import (
|
||||
CopilotStudioAgent,
|
||||
CopilotStudioAgentSettings,
|
||||
CopilotStudioAgentThread,
|
||||
)
|
||||
from semantic_kernel.agents.copilot_studio.copilot_studio_agent import (
|
||||
_CopilotStudioAgentTokenFactory,
|
||||
)
|
||||
from semantic_kernel.agents.copilot_studio.copilot_studio_agent_settings import (
|
||||
CopilotStudioAgentAuthMode,
|
||||
)
|
||||
from semantic_kernel.contents import AuthorRole, ChatMessageContent
|
||||
from semantic_kernel.exceptions import (
|
||||
AgentInitializationException,
|
||||
AgentThreadInitializationException,
|
||||
)
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.kernel import Kernel
|
||||
from semantic_kernel.prompt_template import PromptTemplateConfig
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@pytest.fixture
|
||||
def aiter():
|
||||
async def _aiter(items: list[T]) -> AsyncIterator[T]:
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
return _aiter
|
||||
|
||||
@pytest.fixture
|
||||
def DummyConversation():
|
||||
class DummyConversation:
|
||||
def __init__(self, cid: str):
|
||||
self.id = cid
|
||||
|
||||
return DummyConversation
|
||||
|
||||
@pytest.fixture
|
||||
def DummyActivity(DummyConversation):
|
||||
class DummyActivity:
|
||||
def __init__(self, text: str, cid: str = "conv-123"):
|
||||
self.type = "message"
|
||||
self.text_format = "plain"
|
||||
self.text = text
|
||||
self.suggested_actions = None
|
||||
self.conversation = DummyConversation(cid)
|
||||
|
||||
return DummyActivity
|
||||
|
||||
def test_initialize_thread_with_no_client_throws():
|
||||
with pytest.raises(AgentThreadInitializationException, match="CopilotClient cannot be None"):
|
||||
CopilotStudioAgentThread(client=None)
|
||||
|
||||
def test_normalize_messages():
|
||||
client = MagicMock(spec=csa_mod.CopilotClient)
|
||||
agent = CopilotStudioAgent(client=client)
|
||||
|
||||
single_str = "hello"
|
||||
single_chat = ChatMessageContent(role=AuthorRole.USER, content="hola")
|
||||
mixed = [single_str, single_chat]
|
||||
|
||||
assert agent._normalize_messages(None) == []
|
||||
assert agent._normalize_messages(single_str) == ["hello"]
|
||||
assert agent._normalize_messages(single_chat) == ["hola"]
|
||||
assert agent._normalize_messages(mixed) == ["hello", "hola"]
|
||||
|
||||
def test_plugin_warning_emitted_once(caplog):
|
||||
caplog.set_level(logging.WARNING)
|
||||
|
||||
dummy_kernel = Kernel()
|
||||
dummy_kernel.add_plugin(SimpleNamespace(name="MyPlugin"))
|
||||
|
||||
agent = CopilotStudioAgent(client=MagicMock(spec=csa_mod.CopilotClient), kernel=dummy_kernel)
|
||||
|
||||
warn = [rec for rec in caplog.records if rec.levelno == logging.WARNING]
|
||||
assert len(warn) == 1
|
||||
assert "plugins will be ignored" in warn[0].getMessage()
|
||||
|
||||
caplog.clear()
|
||||
_ = agent.model_copy()
|
||||
assert not caplog.records
|
||||
|
||||
async def test_inner_invoke_prompt_and_yield(monkeypatch, aiter, DummyActivity):
|
||||
client = MagicMock(spec=csa_mod.CopilotClient)
|
||||
prompts: dict[str, str] = {}
|
||||
|
||||
async def fake_ask_question(*, question: str, conversation_id: str):
|
||||
prompts["sent"] = question
|
||||
yield DummyActivity("Aye matey!")
|
||||
|
||||
client.ask_question.side_effect = fake_ask_question
|
||||
client.start_conversation = lambda: aiter([DummyActivity("", "conv-123")])
|
||||
|
||||
monkeypatch.setattr(
|
||||
CopilotStudioAgent,
|
||||
"format_instructions",
|
||||
AsyncMock(return_value="Respond like a pirate"),
|
||||
)
|
||||
|
||||
agent = CopilotStudioAgent(client=client)
|
||||
thread = CopilotStudioAgentThread(client=client)
|
||||
|
||||
replies = [
|
||||
msg
|
||||
async for msg in agent._inner_invoke(
|
||||
thread=thread, messages=["Tell me a joke about bears", "make the joke kid friendly"]
|
||||
)
|
||||
]
|
||||
|
||||
expected_prompt = "Respond like a pirate\nTell me a joke about bears\nmake the joke kid friendly"
|
||||
assert prompts["sent"] == expected_prompt
|
||||
assert len(replies) == 1
|
||||
assert replies[0].content == "Aye matey!"
|
||||
assert replies[0].role is AuthorRole.ASSISTANT
|
||||
|
||||
async def test_get_response(monkeypatch, aiter, DummyActivity):
|
||||
client = MagicMock(spec=csa_mod.CopilotClient)
|
||||
sent: dict[str, str] = {}
|
||||
|
||||
async def fake_ask_question(*, question: str, conversation_id: str):
|
||||
sent["cid"] = conversation_id
|
||||
yield DummyActivity("first", conversation_id)
|
||||
yield DummyActivity("second", conversation_id)
|
||||
|
||||
client.ask_question.side_effect = fake_ask_question
|
||||
client.start_conversation = lambda: aiter([DummyActivity("", "conv-123")])
|
||||
|
||||
monkeypatch.setattr(CopilotStudioAgent, "format_instructions", AsyncMock(return_value=None))
|
||||
|
||||
agent = CopilotStudioAgent(client=client)
|
||||
thread = CopilotStudioAgentThread(client=client)
|
||||
|
||||
item = await agent.get_response(messages="hi there", thread=thread)
|
||||
|
||||
assert item.message.content == "second"
|
||||
assert thread.id == "conv-123"
|
||||
assert sent["cid"] == "conv-123"
|
||||
assert item.thread is thread
|
||||
|
||||
async def test_invoke(monkeypatch, aiter, DummyActivity):
|
||||
client = MagicMock(spec=csa_mod.CopilotClient)
|
||||
sent: dict[str, str] = {}
|
||||
|
||||
async def fake_ask_question(*, question: str, conversation_id: str):
|
||||
sent["cid"] = conversation_id
|
||||
yield DummyActivity("first", conversation_id)
|
||||
yield DummyActivity("second", conversation_id)
|
||||
|
||||
client.ask_question.side_effect = fake_ask_question
|
||||
client.start_conversation = lambda: aiter([DummyActivity("", "conv-123")])
|
||||
|
||||
monkeypatch.setattr(CopilotStudioAgent, "format_instructions", AsyncMock(return_value=None))
|
||||
|
||||
agent = CopilotStudioAgent(
|
||||
client=client,
|
||||
prompt_template_config=PromptTemplateConfig(template="Handle the message in this {{$style}}"),
|
||||
)
|
||||
thread = CopilotStudioAgentThread(client=client)
|
||||
|
||||
responses = []
|
||||
async for response in agent.invoke(
|
||||
messages="hi there", thread=thread, arguments=KernelArguments(style="funny")
|
||||
):
|
||||
responses.append(response)
|
||||
|
||||
item = responses[-1]
|
||||
assert item.message.content == "second"
|
||||
assert thread.id == "conv-123"
|
||||
assert sent["cid"] == "conv-123"
|
||||
assert item.thread is thread
|
||||
|
||||
def test_setup_resources_settings_validation_error():
|
||||
sentinel_exc = ValidationError.from_exception_data("dummy", [], input_type="python")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.agents.copilot_studio.copilot_studio_agent.CopilotStudioAgentSettings",
|
||||
side_effect=sentinel_exc,
|
||||
),
|
||||
pytest.raises(AgentInitializationException, match="Failed to create Copilot Studio Agent settings"),
|
||||
):
|
||||
_ = CopilotStudioAgent.create_client(app_client_id="appid", tenant_id="tenantid")
|
||||
|
||||
def test_setup_resources_missing_ids():
|
||||
dummy_settings = MagicMock(spec=CopilotStudioAgentSettings)
|
||||
dummy_settings.app_client_id = None
|
||||
dummy_settings.tenant_id = None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.agents.copilot_studio.copilot_studio_agent.CopilotStudioAgentSettings",
|
||||
return_value=dummy_settings,
|
||||
),
|
||||
pytest.raises(
|
||||
AgentInitializationException,
|
||||
match="Missing required configuration field\\(s\\): app_client_id, tenant_id",
|
||||
),
|
||||
):
|
||||
_ = CopilotStudioAgent.create_client()
|
||||
|
||||
def test_setup_resources_happy_path(tmp_path, monkeypatch):
|
||||
dummy_settings = MagicMock(spec=CopilotStudioAgentSettings)
|
||||
dummy_settings.app_client_id = "appid"
|
||||
dummy_settings.tenant_id = "tenantid"
|
||||
dummy_settings.auth_mode = CopilotStudioAgentAuthMode.INTERACTIVE
|
||||
dummy_settings.client_secret = SecretStr("test-secret")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"semantic_kernel.agents.copilot_studio.copilot_studio_agent.CopilotStudioAgentSettings",
|
||||
lambda **_: dummy_settings,
|
||||
)
|
||||
monkeypatch.setattr(_CopilotStudioAgentTokenFactory, "acquire", lambda self: "fake-token")
|
||||
|
||||
sentinel_client = MagicMock(spec=csa_mod.CopilotClient)
|
||||
with patch(
|
||||
"semantic_kernel.agents.copilot_studio.copilot_studio_agent.CopilotClient", return_value=sentinel_client
|
||||
) as mock_client_ctor:
|
||||
cache_path = tmp_path / "cache.bin"
|
||||
monkeypatch.setenv("TOKEN_CACHE_PATH", str(cache_path))
|
||||
|
||||
returned = CopilotStudioAgent.create_client(
|
||||
app_client_id="appid",
|
||||
tenant_id="tenantid",
|
||||
environment_id="env-id",
|
||||
agent_identifier="agent-name",
|
||||
auth_mode=CopilotStudioAgentAuthMode.SERVICE,
|
||||
)
|
||||
|
||||
mock_client_ctor.assert_called_once_with(dummy_settings, "fake-token")
|
||||
assert returned is sentinel_client
|
||||
|
||||
class DummyCache:
|
||||
pass
|
||||
|
||||
class FakeAppSilent:
|
||||
def __init__(self, client_id, authority, token_cache, client_credential=None, **kwargs):
|
||||
pass
|
||||
|
||||
def get_accounts(self):
|
||||
return [{"home_account_id": "acct1"}]
|
||||
|
||||
def acquire_token_silent(self, scopes, account):
|
||||
return {"access_token": "silent-token"}
|
||||
|
||||
def acquire_token_interactive(self, scopes):
|
||||
pytest.skip("Unexpected interactive flow in silent test")
|
||||
|
||||
class FakeAppInteractive:
|
||||
def __init__(self, client_id, authority, token_cache):
|
||||
pass
|
||||
|
||||
def get_accounts(self):
|
||||
return []
|
||||
|
||||
def acquire_token_silent(self, scopes, account):
|
||||
pytest.skip("Unexpected silent flow in interactive test")
|
||||
|
||||
def acquire_token_interactive(self, scopes):
|
||||
return {"access_token": "interactive-token"}
|
||||
|
||||
class FakeAppError:
|
||||
def __init__(self, client_id, authority, token_cache):
|
||||
pass
|
||||
|
||||
def get_accounts(self):
|
||||
return []
|
||||
|
||||
def acquire_token_silent(self, scopes, account):
|
||||
return {}
|
||||
|
||||
def acquire_token_interactive(self, scopes):
|
||||
return {
|
||||
"error": "bad",
|
||||
"error_description": "failed",
|
||||
"correlation_id": "cid",
|
||||
}
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def stub_cache(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
_CopilotStudioAgentTokenFactory,
|
||||
"_get_msal_token_cache",
|
||||
staticmethod(lambda cache_path, fallback_to_plaintext=True: DummyCache()),
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fake_app, expected_token, mode",
|
||||
[
|
||||
pytest.param(
|
||||
FakeAppSilent,
|
||||
"silent-token",
|
||||
CopilotStudioAgentAuthMode.SERVICE,
|
||||
marks=pytest.mark.skip(reason="Skipping SERVICE auth mode test as the mode is not yet supported."),
|
||||
),
|
||||
(FakeAppInteractive, "interactive-token", CopilotStudioAgentAuthMode.INTERACTIVE),
|
||||
],
|
||||
)
|
||||
def test_acquire_token_success(monkeypatch, tmp_path, fake_app, expected_token, mode):
|
||||
settings = CopilotStudioAgentSettings(app_client_id="id", tenant_id="tid")
|
||||
cache_path = str(tmp_path / "cache.bin")
|
||||
|
||||
monkeypatch.setattr(csa_mod, "PublicClientApplication", fake_app)
|
||||
monkeypatch.setattr(csa_mod, "ConfidentialClientApplication", fake_app)
|
||||
|
||||
client_secret = None
|
||||
if fake_app == FakeAppSilent:
|
||||
client_secret = "test-secret"
|
||||
|
||||
factory = _CopilotStudioAgentTokenFactory(
|
||||
settings=settings,
|
||||
cache_path=cache_path,
|
||||
mode=mode,
|
||||
client_secret=client_secret,
|
||||
client_certificate=None,
|
||||
user_assertion=None,
|
||||
)
|
||||
token = factory.acquire()
|
||||
assert token == expected_token
|
||||
|
||||
def test_acquire_token_error(monkeypatch, tmp_path):
|
||||
settings = CopilotStudioAgentSettings(app_client_id="id", tenant_id="tid")
|
||||
cache_path = str(tmp_path / "cache.bin")
|
||||
|
||||
monkeypatch.setattr(csa_mod, "PublicClientApplication", FakeAppError)
|
||||
monkeypatch.setattr(csa_mod, "ConfidentialClientApplication", FakeAppError)
|
||||
|
||||
factory = _CopilotStudioAgentTokenFactory(
|
||||
settings=settings,
|
||||
cache_path=cache_path,
|
||||
mode=CopilotStudioAgentAuthMode.INTERACTIVE,
|
||||
client_secret=None,
|
||||
client_certificate=None,
|
||||
user_assertion=None,
|
||||
)
|
||||
with pytest.raises(AgentInitializationException):
|
||||
factory.acquire()
|
||||
@@ -0,0 +1,481 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
Unit tests for OpenAI ResponsesAgent reasoning configuration.
|
||||
|
||||
These tests verify the reasoning functionality for OpenAI ResponsesAgent,
|
||||
including priority hierarchies, parameter validation, and callback handling.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from openai import AsyncOpenAI
|
||||
from openai.types.responses.response_reasoning_item import ResponseReasoningItem
|
||||
from openai.types.responses.response_reasoning_text_delta_event import ResponseReasoningTextDeltaEvent
|
||||
from openai.types.responses.response_reasoning_text_done_event import ResponseReasoningTextDoneEvent
|
||||
|
||||
from semantic_kernel.agents.open_ai.openai_responses_agent import OpenAIResponsesAgent
|
||||
from semantic_kernel.agents.open_ai.responses_agent_thread_actions import ResponsesAgentThreadActions
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.reasoning_content import ReasoningContent
|
||||
from semantic_kernel.contents.streaming_reasoning_content import StreamingReasoningContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions import ContentAdditionException
|
||||
|
||||
|
||||
def test_constructor_reasoning_is_stored():
|
||||
"""Test that reasoning object is stored during construction"""
|
||||
client = AsyncMock(spec=AsyncOpenAI)
|
||||
|
||||
agent = OpenAIResponsesAgent(client=client, ai_model_id="gpt-4o", reasoning={"effort": "high"})
|
||||
|
||||
assert agent.reasoning == {"effort": "high"}
|
||||
|
||||
|
||||
def test_constructor_reasoning_defaults_to_none():
|
||||
"""Test that constructor reasoning defaults to None when not specified."""
|
||||
# Arrange & Act: Create agent without reasoning
|
||||
client = AsyncMock(spec=AsyncOpenAI)
|
||||
agent = OpenAIResponsesAgent(
|
||||
ai_model_id="gpt-4o",
|
||||
client=client,
|
||||
name="TestAgent",
|
||||
# No reasoning specified
|
||||
)
|
||||
|
||||
# Assert: Default reasoning is None
|
||||
assert agent.reasoning is None
|
||||
|
||||
|
||||
def test_reasoning_priority_order_per_invocation_overrides_constructor():
|
||||
"""Test per-invocation reasoning overrides constructor default."""
|
||||
# Arrange: Mock agent with constructor default
|
||||
agent = AsyncMock()
|
||||
agent.ai_model_id = "o1"
|
||||
agent.reasoning = {"effort": "low"} # Constructor setting
|
||||
|
||||
# Act: Override with per-invocation reasoning
|
||||
options = ResponsesAgentThreadActions._generate_options(
|
||||
agent=agent,
|
||||
model="o1",
|
||||
reasoning={"effort": "high"}, # Per-invocation override
|
||||
)
|
||||
|
||||
# Assert: Per-invocation override wins
|
||||
assert options["reasoning"] == {"effort": "high"}
|
||||
|
||||
|
||||
def test_reasoning_priority_order_complete_hierarchy():
|
||||
"""Test complete reasoning priority hierarchy: per-invocation > constructor."""
|
||||
|
||||
# Test 1: Per-invocation has highest priority
|
||||
agent = AsyncMock()
|
||||
agent.ai_model_id = "o1"
|
||||
agent.reasoning = {"effort": "low"}
|
||||
options = ResponsesAgentThreadActions._generate_options(
|
||||
agent=agent,
|
||||
model="o1",
|
||||
reasoning={"effort": "medium"}, # Per-invocation
|
||||
)
|
||||
assert options["reasoning"] == {"effort": "medium"} # Per-invocation wins
|
||||
|
||||
# Test 2: Constructor has priority when no per-invocation
|
||||
agent = AsyncMock()
|
||||
agent.ai_model_id = "o1"
|
||||
agent.reasoning = {"effort": "low"}
|
||||
options = ResponsesAgentThreadActions._generate_options(
|
||||
agent=agent,
|
||||
model="o1",
|
||||
# No per-invocation reasoning
|
||||
)
|
||||
assert options["reasoning"] == {"effort": "low"} # Constructor wins
|
||||
|
||||
# Test 3: No reasoning when neither constructor nor per-invocation provided
|
||||
agent = AsyncMock()
|
||||
agent.ai_model_id = "o1"
|
||||
agent.reasoning = None # No constructor reasoning
|
||||
options = ResponsesAgentThreadActions._generate_options(agent=agent, model="o1")
|
||||
assert "reasoning" not in options # No automatic defaults
|
||||
|
||||
|
||||
def test_multi_agent_reasoning_isolation():
|
||||
"""Test multiple agents maintain separate reasoning configurations."""
|
||||
client = AsyncMock(spec=AsyncOpenAI)
|
||||
|
||||
# Agent 1 with low reasoning
|
||||
low_agent = OpenAIResponsesAgent(ai_model_id="o1", client=client, name="LowAgent", reasoning={"effort": "low"})
|
||||
|
||||
# Agent 2 with high reasoning
|
||||
high_agent = OpenAIResponsesAgent(ai_model_id="o1", client=client, name="HighAgent", reasoning={"effort": "high"})
|
||||
|
||||
# Assert: Agents maintain separate defaults
|
||||
assert low_agent.reasoning == {"effort": "low"}
|
||||
assert high_agent.reasoning == {"effort": "high"}
|
||||
|
||||
# Verify isolation through options generation
|
||||
low_options = ResponsesAgentThreadActions._generate_options(agent=low_agent, model="o1")
|
||||
high_options = ResponsesAgentThreadActions._generate_options(agent=high_agent, model="o1")
|
||||
|
||||
assert low_options["reasoning"] == {"effort": "low"}
|
||||
assert high_options["reasoning"] == {"effort": "high"}
|
||||
|
||||
|
||||
def test_reasoning_validation_not_available():
|
||||
"""Test that validation method was removed in simplified implementation."""
|
||||
# The validation method was removed, so this test now checks that it's not available
|
||||
assert not hasattr(ResponsesAgentThreadActions, "_validate_reasoning_effort_parameter")
|
||||
|
||||
|
||||
def test_explicit_none_reasoning_disables_reasoning():
|
||||
"""Test explicitly setting reasoning=None disables reasoning."""
|
||||
agent = AsyncMock()
|
||||
agent.ai_model_id = "o1"
|
||||
agent.reasoning = None
|
||||
|
||||
# Explicitly disable reasoning
|
||||
options = ResponsesAgentThreadActions._generate_options(
|
||||
agent=agent,
|
||||
model="o1",
|
||||
reasoning=None, # Explicit None
|
||||
)
|
||||
|
||||
# Explicit None should disable reasoning
|
||||
assert "reasoning" not in options
|
||||
|
||||
|
||||
def test_reasoning_object_structure_follows_openai_api():
|
||||
"""Test reasoning parameter is correctly structured for OpenAI API."""
|
||||
agent = AsyncMock()
|
||||
agent.ai_model_id = "o1"
|
||||
agent.reasoning = {"effort": "high", "summary": "auto"}
|
||||
|
||||
options = ResponsesAgentThreadActions._generate_options(agent=agent, model="o1")
|
||||
|
||||
# Verify correct OpenAI API structure
|
||||
assert "reasoning" in options
|
||||
reasoning = options["reasoning"]
|
||||
assert isinstance(reasoning, dict)
|
||||
assert reasoning == {"effort": "high", "summary": "auto"}
|
||||
|
||||
|
||||
def test_reasoning_object_pass_through():
|
||||
"""Test that reasoning objects are passed through directly."""
|
||||
agent = AsyncMock()
|
||||
agent.ai_model_id = "o1"
|
||||
agent.reasoning = None
|
||||
|
||||
# Test different reasoning object structures
|
||||
test_cases = [
|
||||
{"effort": "low"},
|
||||
{"effort": "medium", "summary": "concise"},
|
||||
{"effort": "high", "summary": "detailed"},
|
||||
{"effort": "minimal", "generate_summary": "auto"}, # deprecated field
|
||||
]
|
||||
|
||||
for reasoning_obj in test_cases:
|
||||
options = ResponsesAgentThreadActions._generate_options(agent=agent, model="o1", reasoning=reasoning_obj)
|
||||
assert options["reasoning"] == reasoning_obj
|
||||
|
||||
|
||||
def test_get_reasoning_items_from_output():
|
||||
"""Test extraction of reasoning items from response output."""
|
||||
# Create mock ResponseReasoningItem
|
||||
mock_reasoning_item = MagicMock(spec=ResponseReasoningItem)
|
||||
mock_reasoning_item.id = "reasoning-123"
|
||||
mock_reasoning_item.content = "The model is thinking..."
|
||||
mock_reasoning_item.summary = "Analyzed the problem"
|
||||
mock_reasoning_item.status = "completed"
|
||||
|
||||
# Create mock ResponsesAgentThreadActions._create_reasoning_content_from_openai_item method
|
||||
expected_reasoning_content = MagicMock(spec=ReasoningContent)
|
||||
|
||||
with patch.object(
|
||||
ResponsesAgentThreadActions,
|
||||
"_create_reasoning_content_from_openai_item",
|
||||
return_value=expected_reasoning_content,
|
||||
):
|
||||
# Test with reasoning item in output
|
||||
output_with_reasoning = [mock_reasoning_item, MagicMock()]
|
||||
result = ResponsesAgentThreadActions._get_reasoning_items_from_output(output_with_reasoning)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == expected_reasoning_content
|
||||
ResponsesAgentThreadActions._create_reasoning_content_from_openai_item.assert_called_once_with(
|
||||
mock_reasoning_item
|
||||
)
|
||||
|
||||
|
||||
def test_get_reasoning_items_from_output_empty():
|
||||
"""Test extraction with no reasoning items in output."""
|
||||
# Test with no reasoning items
|
||||
output_without_reasoning = [MagicMock(), MagicMock()]
|
||||
result = ResponsesAgentThreadActions._get_reasoning_items_from_output(output_without_reasoning)
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_get_reasoning_items_from_output_mixed():
|
||||
"""Test extraction with mixed output types including reasoning."""
|
||||
# Create mock items
|
||||
mock_reasoning_item1 = MagicMock(spec=ResponseReasoningItem)
|
||||
mock_reasoning_item2 = MagicMock(spec=ResponseReasoningItem)
|
||||
mock_other_item = MagicMock()
|
||||
|
||||
expected_reasoning1 = MagicMock(spec=ReasoningContent)
|
||||
expected_reasoning2 = MagicMock(spec=ReasoningContent)
|
||||
|
||||
with patch.object(
|
||||
ResponsesAgentThreadActions,
|
||||
"_create_reasoning_content_from_openai_item",
|
||||
side_effect=[expected_reasoning1, expected_reasoning2],
|
||||
):
|
||||
output_mixed = [mock_other_item, mock_reasoning_item1, mock_reasoning_item2]
|
||||
result = ResponsesAgentThreadActions._get_reasoning_items_from_output(output_mixed)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0] == expected_reasoning1
|
||||
assert result[1] == expected_reasoning2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reasoning_config,expected_summary",
|
||||
[
|
||||
({"effort": "high"}, None),
|
||||
({"effort": "high", "summary": "detailed"}, "detailed"),
|
||||
({"effort": "medium", "summary": "concise"}, "concise"),
|
||||
({"effort": "low", "summary": "auto"}, "auto"),
|
||||
],
|
||||
)
|
||||
def test_reasoning_summary_configuration(reasoning_config, expected_summary):
|
||||
"""Test that reasoning summary configuration is properly handled."""
|
||||
agent = AsyncMock()
|
||||
agent.ai_model_id = "o1"
|
||||
agent.reasoning = None
|
||||
|
||||
options = ResponsesAgentThreadActions._generate_options(agent=agent, model="o1", reasoning=reasoning_config)
|
||||
|
||||
assert options["reasoning"] == reasoning_config
|
||||
if expected_summary:
|
||||
assert options["reasoning"]["summary"] == expected_summary
|
||||
|
||||
|
||||
def test_streaming_reasoning_content_creation():
|
||||
"""Test StreamingReasoningContent creation and basic functionality."""
|
||||
# Test basic creation
|
||||
reasoning = StreamingReasoningContent(text="Initial reasoning", choice_index=0)
|
||||
|
||||
assert reasoning.text == "Initial reasoning"
|
||||
assert reasoning.choice_index == 0
|
||||
assert str(reasoning) == "Initial reasoning"
|
||||
assert bytes(reasoning) == b"Initial reasoning"
|
||||
|
||||
|
||||
def test_streaming_reasoning_content_addition():
|
||||
"""Test StreamingReasoningContent __add__ method."""
|
||||
reasoning1 = StreamingReasoningContent(
|
||||
text="First part", choice_index=0, ai_model_id="gpt-4o", metadata={"key1": "value1"}
|
||||
)
|
||||
reasoning2 = StreamingReasoningContent(
|
||||
text=" second part", choice_index=0, ai_model_id="gpt-4o", metadata={"key2": "value2"}
|
||||
)
|
||||
|
||||
combined = reasoning1 + reasoning2
|
||||
|
||||
assert combined.text == "First part second part"
|
||||
assert combined.choice_index == 0
|
||||
assert combined.ai_model_id == "gpt-4o"
|
||||
assert combined.metadata == {"key1": "value1", "key2": "value2"}
|
||||
|
||||
|
||||
def test_streaming_reasoning_content_addition_errors():
|
||||
"""Test StreamingReasoningContent addition error conditions."""
|
||||
reasoning1 = StreamingReasoningContent(text="text1", choice_index=0, ai_model_id="model1")
|
||||
reasoning2 = StreamingReasoningContent(text="text2", choice_index=1, ai_model_id="model1")
|
||||
reasoning3 = StreamingReasoningContent(text="text3", choice_index=0, ai_model_id="model2")
|
||||
|
||||
# Different choice_index should raise error
|
||||
with pytest.raises(ContentAdditionException, match="different choice_index"):
|
||||
reasoning1 + reasoning2
|
||||
|
||||
# Different ai_model_id should raise error
|
||||
with pytest.raises(ContentAdditionException, match="different ai_model_id"):
|
||||
reasoning1 + reasoning3
|
||||
|
||||
|
||||
def test_streaming_reasoning_content_with_regular_reasoning():
|
||||
"""Test StreamingReasoningContent addition with regular ReasoningContent."""
|
||||
streaming = StreamingReasoningContent(
|
||||
text="Stream: ", choice_index=0, ai_model_id="gpt-4o", metadata={"stream": True}
|
||||
)
|
||||
regular = ReasoningContent(text="regular", ai_model_id="gpt-4o", metadata={"regular": True})
|
||||
|
||||
combined = streaming + regular
|
||||
|
||||
assert isinstance(combined, StreamingReasoningContent)
|
||||
assert combined.text == "Stream: regular"
|
||||
assert combined.choice_index == 0
|
||||
assert combined.ai_model_id == "gpt-4o"
|
||||
assert combined.metadata == {"stream": True, "regular": True}
|
||||
|
||||
|
||||
def test_reasoning_content_from_response_item():
|
||||
"""Test ReasoningContent creation from OpenAI ResponseReasoningItem via agent thread actions."""
|
||||
mock_item = MagicMock(spec=ResponseReasoningItem)
|
||||
mock_item.id = "reasoning-123"
|
||||
mock_item.summary = [MagicMock(text="Analyzed the user's request")]
|
||||
mock_item.status = "completed"
|
||||
|
||||
# Test the _create_reasoning_content_from_openai_item method via thread actions
|
||||
reasoning = ResponsesAgentThreadActions._create_reasoning_content_from_openai_item(mock_item)
|
||||
|
||||
assert isinstance(reasoning, ReasoningContent)
|
||||
assert reasoning.text == "Analyzed the user's request"
|
||||
assert reasoning.metadata["id"] == "reasoning-123"
|
||||
assert reasoning.metadata["status"] == "completed"
|
||||
|
||||
|
||||
def test_callback_signature_validation():
|
||||
"""Test that on_intermediate_message callback has correct signature."""
|
||||
|
||||
async def valid_callback(message: ChatMessageContent) -> None:
|
||||
"""Valid callback signature."""
|
||||
pass
|
||||
|
||||
async def invalid_callback(message: str) -> None:
|
||||
"""Invalid callback signature."""
|
||||
pass
|
||||
|
||||
# This test verifies the expected signature pattern exists
|
||||
# In actual usage, the callback should accept ChatMessageContent
|
||||
test_message = ChatMessageContent(role=AuthorRole.ASSISTANT, items=[ReasoningContent(text="reasoning")])
|
||||
|
||||
# Valid callback should work (this is a type check test)
|
||||
assert callable(valid_callback)
|
||||
assert test_message.role == AuthorRole.ASSISTANT
|
||||
# Note: Runtime type checking would be done by the agent implementation
|
||||
|
||||
|
||||
@patch.object(ResponsesAgentThreadActions, "_get_reasoning_items_from_output")
|
||||
def test_reasoning_yield_pattern(mock_get_reasoning):
|
||||
"""Test that reasoning content yields False (intermediate) while final content yields True."""
|
||||
# Mock reasoning items being found
|
||||
mock_reasoning_content = ReasoningContent(text="Thinking about the answer...")
|
||||
mock_get_reasoning.return_value = [mock_reasoning_content]
|
||||
|
||||
# In actual usage, when reasoning items are found:
|
||||
# yield False, reasoning_message # <- Intermediate (not visible to user)
|
||||
# yield True, final_message # <- Final response (visible to user)
|
||||
|
||||
# This test verifies the pattern exists in the invoke method
|
||||
result = ResponsesAgentThreadActions._get_reasoning_items_from_output([])
|
||||
mock_get_reasoning.assert_called_once()
|
||||
assert result is not None # Verify the method returns something
|
||||
|
||||
|
||||
def test_streaming_reasoning_events():
|
||||
"""Test handling of streaming reasoning events."""
|
||||
# Test delta event
|
||||
delta_event = MagicMock(spec=ResponseReasoningTextDeltaEvent)
|
||||
delta_event.delta = "Thinking"
|
||||
delta_event.item_id = "reasoning-123"
|
||||
|
||||
# Test done event
|
||||
done_event = MagicMock(spec=ResponseReasoningTextDoneEvent)
|
||||
done_event.text = "Thinking process complete"
|
||||
done_event.item_id = "reasoning-123"
|
||||
|
||||
# Verify events have expected attributes
|
||||
assert hasattr(delta_event, "delta")
|
||||
assert hasattr(done_event, "text")
|
||||
|
||||
# These would be processed in invoke_stream method
|
||||
# Delta events create StreamingReasoningContent
|
||||
# Done events create ReasoningContent
|
||||
|
||||
|
||||
def test_reasoning_metadata_handling():
|
||||
"""Test that reasoning content properly handles metadata."""
|
||||
reasoning = ReasoningContent(text="Analysis complete", metadata={"model": "gpt-4o", "reasoning_effort": "high"})
|
||||
|
||||
streaming_reasoning = StreamingReasoningContent(
|
||||
text="Analyzing...", choice_index=0, metadata={"stream": True, "chunk": 1}
|
||||
)
|
||||
|
||||
assert reasoning.metadata["model"] == "gpt-4o"
|
||||
assert reasoning.metadata["reasoning_effort"] == "high"
|
||||
assert streaming_reasoning.metadata["stream"] is True
|
||||
assert streaming_reasoning.metadata["chunk"] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text_input,expected_bytes",
|
||||
[
|
||||
("Simple reasoning", b"Simple reasoning"),
|
||||
("", b""),
|
||||
("Unicode: 🤔", "Unicode: 🤔".encode()),
|
||||
],
|
||||
)
|
||||
def test_streaming_reasoning_content_bytes_conversion(text_input, expected_bytes):
|
||||
"""Test StreamingReasoningContent bytes conversion with various inputs."""
|
||||
reasoning = StreamingReasoningContent(text=text_input, choice_index=0)
|
||||
assert bytes(reasoning) == expected_bytes
|
||||
|
||||
|
||||
def test_streaming_reasoning_content_default_text():
|
||||
"""Test StreamingReasoningContent with default text value."""
|
||||
# Test with no text parameter (should default to empty string)
|
||||
reasoning_default = StreamingReasoningContent(choice_index=0)
|
||||
assert reasoning_default.text is None
|
||||
assert str(reasoning_default) == ""
|
||||
assert bytes(reasoning_default) == b""
|
||||
|
||||
# Test with empty string
|
||||
reasoning_empty = StreamingReasoningContent(text="", choice_index=0)
|
||||
assert reasoning_empty.text == ""
|
||||
assert str(reasoning_empty) == ""
|
||||
assert bytes(reasoning_empty) == b""
|
||||
|
||||
|
||||
def test_reasoning_integration_flow():
|
||||
"""Test the complete flow of reasoning content through the system."""
|
||||
# 1. OpenAI returns ResponseReasoningItem
|
||||
mock_reasoning_item = MagicMock(spec=ResponseReasoningItem)
|
||||
mock_reasoning_item.content = "Let me analyze this step by step..."
|
||||
|
||||
# 2. Convert to ReasoningContent
|
||||
reasoning_content = ReasoningContent(text="Let me analyze this step by step...")
|
||||
|
||||
# 3. Create ChatMessageContent with reasoning
|
||||
reasoning_message = ChatMessageContent(role=AuthorRole.ASSISTANT, items=[reasoning_content], ai_model_id="gpt-4o")
|
||||
|
||||
# 4. Verify message structure
|
||||
assert len(reasoning_message.items) == 1
|
||||
assert isinstance(reasoning_message.items[0], ReasoningContent)
|
||||
assert reasoning_message.items[0].text == "Let me analyze this step by step..."
|
||||
assert reasoning_message.role == AuthorRole.ASSISTANT
|
||||
|
||||
|
||||
def test_multiple_reasoning_items_extraction():
|
||||
"""Test extraction of multiple reasoning items from response output."""
|
||||
# Create multiple mock reasoning items
|
||||
reasoning1 = MagicMock(spec=ResponseReasoningItem)
|
||||
reasoning1.content = "First reasoning step"
|
||||
|
||||
reasoning2 = MagicMock(spec=ResponseReasoningItem)
|
||||
reasoning2.content = "Second reasoning step"
|
||||
|
||||
other_item = MagicMock() # Non-reasoning item
|
||||
|
||||
output = [reasoning1, other_item, reasoning2]
|
||||
|
||||
with patch.object(
|
||||
ResponsesAgentThreadActions,
|
||||
"_create_reasoning_content_from_openai_item",
|
||||
side_effect=lambda x: ReasoningContent(text=x.content),
|
||||
):
|
||||
result = ResponsesAgentThreadActions._get_reasoning_items_from_output(output)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].text == "First reasoning step"
|
||||
assert result[1].text == "Second reasoning step"
|
||||
@@ -0,0 +1,105 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from openai import AsyncOpenAI
|
||||
from openai.types.beta.assistant import Assistant
|
||||
from openai.types.beta.threads.file_citation_annotation import FileCitation, FileCitationAnnotation
|
||||
from openai.types.beta.threads.file_path_annotation import FilePath, FilePathAnnotation
|
||||
from openai.types.beta.threads.image_file import ImageFile
|
||||
from openai.types.beta.threads.image_file_content_block import ImageFileContentBlock
|
||||
from openai.types.beta.threads.text import Text
|
||||
from openai.types.beta.threads.text_content_block import TextContentBlock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_thread():
|
||||
class MockThread:
|
||||
id = "test_thread_id"
|
||||
|
||||
return MockThread()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_thread_messages():
|
||||
class MockMessage:
|
||||
def __init__(self, id, role, content, assistant_id=None):
|
||||
self.id = id
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.assistant_id = assistant_id
|
||||
|
||||
return [
|
||||
MockMessage(
|
||||
id="test_message_id_1",
|
||||
role="user",
|
||||
content=[
|
||||
TextContentBlock(
|
||||
type="text",
|
||||
text=Text(
|
||||
value="Hello",
|
||||
annotations=[
|
||||
FilePathAnnotation(
|
||||
type="file_path",
|
||||
file_path=FilePath(file_id="test_file_id"),
|
||||
end_index=5,
|
||||
start_index=0,
|
||||
text="Hello",
|
||||
),
|
||||
FileCitationAnnotation(
|
||||
type="file_citation",
|
||||
file_citation=FileCitation(file_id="test_file_id"),
|
||||
text="Hello",
|
||||
start_index=0,
|
||||
end_index=5,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
MockMessage(
|
||||
id="test_message_id_2",
|
||||
role="assistant",
|
||||
content=[
|
||||
ImageFileContentBlock(type="image_file", image_file=ImageFile(file_id="test_file_id", detail="auto"))
|
||||
],
|
||||
assistant_id="assistant_1",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def openai_client(assistant_definition, mock_thread, mock_thread_messages) -> AsyncMock:
|
||||
async def mock_list_messages(*args, **kwargs) -> Any:
|
||||
return MagicMock(data=mock_thread_messages)
|
||||
|
||||
async def mock_retrieve_assistant(*args, **kwargs) -> Any:
|
||||
asst = AsyncMock(spec=Assistant)
|
||||
asst.name = "test-assistant"
|
||||
return asst
|
||||
|
||||
client = AsyncMock(spec=AsyncOpenAI)
|
||||
client.beta = MagicMock()
|
||||
client.beta.assistants = MagicMock()
|
||||
client.beta.assistants.create = AsyncMock(return_value=assistant_definition)
|
||||
client.beta.assistants.retrieve = AsyncMock(side_effect=mock_retrieve_assistant)
|
||||
client.beta.threads = MagicMock()
|
||||
client.beta.threads.create = AsyncMock(return_value=mock_thread)
|
||||
client.beta.threads.messages = MagicMock()
|
||||
client.beta.threads.messages.list = AsyncMock(side_effect=mock_list_messages)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def assistant_definition() -> AsyncMock:
|
||||
definition = AsyncMock(spec=Assistant)
|
||||
definition.id = "agent123"
|
||||
definition.name = "agentName"
|
||||
definition.description = "desc"
|
||||
definition.instructions = "test agent"
|
||||
|
||||
return definition
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,468 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from openai import AsyncOpenAI
|
||||
from openai.types.beta.assistant import Assistant
|
||||
from openai.types.beta.threads.file_citation_annotation import FileCitation, FileCitationAnnotation
|
||||
from openai.types.beta.threads.file_path_annotation import FilePath, FilePathAnnotation
|
||||
from openai.types.beta.threads.image_file import ImageFile
|
||||
from openai.types.beta.threads.image_file_content_block import ImageFileContentBlock
|
||||
from openai.types.beta.threads.text import Text
|
||||
from openai.types.beta.threads.text_content_block import TextContentBlock
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from semantic_kernel.agents import AgentRegistry
|
||||
from semantic_kernel.agents.open_ai.azure_assistant_agent import AzureAssistantAgent
|
||||
from semantic_kernel.agents.open_ai.openai_assistant_agent import AssistantAgentThread
|
||||
from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentInitializationException
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
from semantic_kernel.functions.kernel_plugin import KernelPlugin
|
||||
from semantic_kernel.kernel import Kernel
|
||||
from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_azure_openai_client_and_definition():
|
||||
client = AsyncMock(spec=AsyncOpenAI)
|
||||
client.beta = MagicMock()
|
||||
client.beta.assistants = MagicMock()
|
||||
|
||||
definition = AsyncMock(spec=Assistant)
|
||||
definition.id = "agent123"
|
||||
definition.name = "DeclarativeAgent"
|
||||
definition.description = "desc"
|
||||
definition.instructions = "test agent"
|
||||
definition.tools = []
|
||||
definition.model = "gpt-4o"
|
||||
definition.temperature = 1.0
|
||||
definition.top_p = 1.0
|
||||
definition.metadata = {}
|
||||
|
||||
client.beta.assistants.create = AsyncMock(return_value=definition)
|
||||
|
||||
return client, definition
|
||||
|
||||
|
||||
class SamplePlugin:
|
||||
@kernel_function
|
||||
def test_plugin(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class ResponseModelPydantic(BaseModel):
|
||||
response: str
|
||||
items: list[str]
|
||||
|
||||
|
||||
class ResponseModelNonPydantic:
|
||||
response: str
|
||||
items: list[str]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_thread_messages():
|
||||
class MockMessage:
|
||||
def __init__(self, id, role, content, assistant_id=None):
|
||||
self.id = id
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.assistant_id = assistant_id
|
||||
|
||||
return [
|
||||
MockMessage(
|
||||
id="test_message_id_1",
|
||||
role="user",
|
||||
content=[
|
||||
TextContentBlock(
|
||||
type="text",
|
||||
text=Text(
|
||||
value="Hello",
|
||||
annotations=[
|
||||
FilePathAnnotation(
|
||||
type="file_path",
|
||||
file_path=FilePath(file_id="test_file_id"),
|
||||
end_index=5,
|
||||
start_index=0,
|
||||
text="Hello",
|
||||
),
|
||||
FileCitationAnnotation(
|
||||
type="file_citation",
|
||||
file_citation=FileCitation(file_id="test_file_id"),
|
||||
text="Hello",
|
||||
start_index=0,
|
||||
end_index=5,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
MockMessage(
|
||||
id="test_message_id_2",
|
||||
role="assistant",
|
||||
content=[
|
||||
ImageFileContentBlock(type="image_file", image_file=ImageFile(file_id="test_file_id", detail="auto"))
|
||||
],
|
||||
assistant_id="assistant_1",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def test_open_ai_assistant_agent_init():
|
||||
sample_prompt_template_config = PromptTemplateConfig(
|
||||
template="template",
|
||||
)
|
||||
|
||||
kernel_plugin = KernelPlugin(name="expected_plugin_name", description="expected_plugin_description")
|
||||
|
||||
client = AsyncMock(spec=AsyncOpenAI)
|
||||
definition = AsyncMock(spec=Assistant)
|
||||
definition.id = "agent123"
|
||||
definition.name = "agentName"
|
||||
definition.description = "desc"
|
||||
definition.instructions = "test agent"
|
||||
agent = AzureAssistantAgent(
|
||||
client=client,
|
||||
definition=definition,
|
||||
arguments=KernelArguments(test="test"),
|
||||
kernel=AsyncMock(spec=Kernel),
|
||||
plugins=[SamplePlugin(), kernel_plugin],
|
||||
polling_options=AsyncMock(spec=RunPollingOptions),
|
||||
prompt_template_config=sample_prompt_template_config, # type: ignore
|
||||
other_arg="test", # type: ignore
|
||||
)
|
||||
assert agent.id == "agent123"
|
||||
assert agent.name == "agentName"
|
||||
assert agent.description == "desc"
|
||||
|
||||
|
||||
def test_azure_open_ai_settings_create_throws(azure_openai_unit_test_env):
|
||||
with patch(
|
||||
"semantic_kernel.connectors.ai.open_ai.settings.azure_open_ai_settings.AzureOpenAISettings.__init__"
|
||||
) as mock_create:
|
||||
mock_create.side_effect = ValidationError.from_exception_data("test", line_errors=[], input_type="python")
|
||||
|
||||
with pytest.raises(AgentInitializationException, match="Failed to create Azure OpenAI settings."):
|
||||
_, _ = AzureAssistantAgent.setup_resources(api_key="test_api_key")
|
||||
|
||||
|
||||
def test_open_ai_assistant_with_code_interpreter_tool():
|
||||
tools, resources = AzureAssistantAgent.configure_code_interpreter_tool(file_ids=["file_id"])
|
||||
assert tools is not None
|
||||
assert resources is not None
|
||||
|
||||
|
||||
def test_open_ai_assistant_with_file_search_tool():
|
||||
tools, resources = AzureAssistantAgent.configure_file_search_tool(vector_store_ids=["vector_store_id"])
|
||||
assert tools is not None
|
||||
assert resources is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model, json_schema_expected",
|
||||
[
|
||||
pytest.param(ResponseModelPydantic, True),
|
||||
pytest.param(ResponseModelNonPydantic, True),
|
||||
pytest.param({"type": "json_object"}, False),
|
||||
pytest.param({"type": "json_schema", "json_schema": {"schema": {}}}, False),
|
||||
],
|
||||
)
|
||||
def test_configure_response_format(model, json_schema_expected):
|
||||
response_format = AzureAssistantAgent.configure_response_format(model)
|
||||
assert response_format is not None
|
||||
if json_schema_expected:
|
||||
assert response_format["json_schema"] is not None # type: ignore
|
||||
|
||||
|
||||
def test_configure_response_format_unexpected_type():
|
||||
with pytest.raises(AgentInitializationException) as exc_info:
|
||||
AzureAssistantAgent.configure_response_format({"type": "invalid_type"})
|
||||
assert "Encountered unexpected response_format type" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_configure_response_format_json_schema_invalid_schema():
|
||||
with pytest.raises(AgentInitializationException) as exc_info:
|
||||
AzureAssistantAgent.configure_response_format({"type": "json_schema", "json_schema": "not_a_dict"})
|
||||
assert "If response_format has type 'json_schema'" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_configure_response_format_invalid_input_type():
|
||||
with pytest.raises(AgentInitializationException) as exc_info:
|
||||
AzureAssistantAgent.configure_response_format(3) # type: ignore
|
||||
assert "response_format must be a dictionary" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments, include_args",
|
||||
[
|
||||
pytest.param({"extra_args": "extra_args"}, True),
|
||||
pytest.param(None, False),
|
||||
],
|
||||
)
|
||||
async def test_open_ai_assistant_agent_invoke(arguments, include_args):
|
||||
client = AsyncMock(spec=AsyncOpenAI)
|
||||
definition = AsyncMock(spec=Assistant)
|
||||
definition.id = "agent123"
|
||||
definition.name = "agentName"
|
||||
definition.description = "desc"
|
||||
definition.instructions = "test agent"
|
||||
definition.tools = []
|
||||
definition.model = "gpt-4o"
|
||||
definition.response_format = {"type": "json_object"}
|
||||
definition.temperature = 0.1
|
||||
definition.top_p = 0.9
|
||||
definition.metadata = {}
|
||||
agent = AzureAssistantAgent(client=client, definition=definition)
|
||||
mock_thread = AsyncMock(spec=AssistantAgentThread)
|
||||
results = []
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
kwargs = None
|
||||
if include_args:
|
||||
kwargs = arguments
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.open_ai.assistant_thread_actions.AssistantThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for item in agent.invoke(messages="test", thread=mock_thread, **(kwargs or {})):
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments, include_args",
|
||||
[
|
||||
pytest.param({"extra_args": "extra_args"}, True),
|
||||
pytest.param(None, False),
|
||||
],
|
||||
)
|
||||
async def test_open_ai_assistant_agent_invoke_stream(arguments, include_args):
|
||||
client = AsyncMock(spec=AsyncOpenAI)
|
||||
definition = AsyncMock(spec=Assistant)
|
||||
definition.id = "agent123"
|
||||
definition.name = "agentName"
|
||||
definition.description = "desc"
|
||||
definition.instructions = "test agent"
|
||||
agent = AzureAssistantAgent(client=client, definition=definition)
|
||||
mock_thread = AsyncMock(spec=AssistantAgentThread)
|
||||
results = []
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
kwargs = None
|
||||
if include_args:
|
||||
kwargs = arguments
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.open_ai.assistant_thread_actions.AssistantThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for item in agent.invoke_stream(messages="test", thread=mock_thread, **(kwargs or {})):
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
def test_open_ai_assistant_agent_get_channel_keys():
|
||||
client = AsyncMock(spec=AsyncOpenAI)
|
||||
definition = AsyncMock(spec=Assistant)
|
||||
definition.id = "agent123"
|
||||
definition.name = "agentName"
|
||||
definition.description = "desc"
|
||||
definition.instructions = "test agent"
|
||||
agent = AzureAssistantAgent(client=client, definition=definition)
|
||||
keys = list(agent.get_channel_keys())
|
||||
assert len(keys) >= 3
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_thread():
|
||||
class MockThread:
|
||||
id = "test_thread_id"
|
||||
|
||||
return MockThread()
|
||||
|
||||
|
||||
async def test_open_ai_assistant_agent_create_channel(mock_thread):
|
||||
from semantic_kernel.agents.channels.open_ai_assistant_channel import OpenAIAssistantChannel
|
||||
|
||||
client = AsyncMock(spec=AsyncOpenAI)
|
||||
definition = AsyncMock(spec=Assistant)
|
||||
definition.id = "agent123"
|
||||
definition.name = "agentName"
|
||||
definition.description = "desc"
|
||||
definition.instructions = "test agent"
|
||||
agent = AzureAssistantAgent(client=client, definition=definition)
|
||||
client.beta = MagicMock()
|
||||
client.beta.assistants = MagicMock()
|
||||
client.beta.assistants.create = AsyncMock(return_value=definition)
|
||||
client.beta.threads = MagicMock()
|
||||
client.beta.threads.create = AsyncMock(return_value=mock_thread)
|
||||
ch = await agent.create_channel()
|
||||
assert isinstance(ch, OpenAIAssistantChannel)
|
||||
assert ch.thread_id == "test_thread_id"
|
||||
|
||||
|
||||
def test_create_openai_client(azure_openai_unit_test_env):
|
||||
client, model = AzureAssistantAgent.setup_resources(api_key="test_api_key", default_headers={"user_agent": "test"})
|
||||
assert client is not None
|
||||
assert client.api_key == "test_api_key"
|
||||
assert model is not None
|
||||
|
||||
|
||||
def test_create_azure_openai_client(azure_openai_unit_test_env):
|
||||
client, model = AzureAssistantAgent.setup_resources(
|
||||
api_key="test_api_key", endpoint="https://test_endpoint.com", default_headers={"user_agent": "test"}
|
||||
)
|
||||
assert model is not None
|
||||
assert client is not None
|
||||
assert client.api_key == "test_api_key"
|
||||
assert str(client.base_url) == "https://test_endpoint.com/openai/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_ENDPOINT"]], indirect=True)
|
||||
async def test_retrieve_agent_missing_endpoint_throws(kernel, azure_openai_unit_test_env):
|
||||
with pytest.raises(AgentInitializationException, match="Please provide an Azure OpenAI endpoint"):
|
||||
_, _ = AzureAssistantAgent.setup_resources(
|
||||
env_file_path="./", api_key="test_api_key", default_headers={"user_agent": "test"}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]], indirect=True)
|
||||
async def test_retrieve_agent_missing_chat_deployment_name_throws(kernel, azure_openai_unit_test_env):
|
||||
with pytest.raises(AgentInitializationException, match="Please provide an Azure OpenAI deployment name"):
|
||||
_, _ = AzureAssistantAgent.setup_resources(
|
||||
env_file_path="./",
|
||||
api_key="test_api_key",
|
||||
endpoint="https://test_endpoint.com",
|
||||
default_headers={"user_agent": "test"},
|
||||
)
|
||||
|
||||
|
||||
async def test_azure_assistant_agent_from_yaml_minimal(
|
||||
azure_openai_unit_test_env, mock_azure_openai_client_and_definition
|
||||
):
|
||||
spec = """
|
||||
type: azure_assistant
|
||||
name: MinimalAgent
|
||||
model:
|
||||
id: ${AzureOpenAI:ChatModelId}
|
||||
connection:
|
||||
api_key: ${AzureOpenAI:ApiKey}
|
||||
endpoint: ${AzureOpenAI:Endpoint}
|
||||
"""
|
||||
client, definition = mock_azure_openai_client_and_definition
|
||||
definition.name = "MinimalAgent"
|
||||
agent = await AgentRegistry.create_from_yaml(spec, client=client)
|
||||
assert isinstance(agent, AzureAssistantAgent)
|
||||
assert agent.name == "MinimalAgent"
|
||||
assert agent.definition.model == "gpt-4o"
|
||||
|
||||
|
||||
async def test_azure_assistant_agent_with_tools(azure_openai_unit_test_env, mock_azure_openai_client_and_definition):
|
||||
spec = """
|
||||
type: azure_assistant
|
||||
name: CodeAgent
|
||||
description: Uses code interpreter.
|
||||
model:
|
||||
id: ${AzureOpenAI:ChatModelId}
|
||||
connection:
|
||||
api_key: ${AzureOpenAI:ApiKey}
|
||||
endpoint: ${AzureOpenAI:Endpoint}
|
||||
tools:
|
||||
- type: code_interpreter
|
||||
options:
|
||||
file_ids:
|
||||
- ${AzureOpenAI:FileId1}
|
||||
"""
|
||||
client, definition = mock_azure_openai_client_and_definition
|
||||
definition.name = "CodeAgent"
|
||||
definition.tools = [{"type": "code_interpreter", "options": {"file_ids": ["file-123"]}}]
|
||||
agent = await AgentRegistry.create_from_yaml(spec, client=client, extras={"AzureOpenAI:FileId1": "file-123"})
|
||||
assert agent.name == "CodeAgent"
|
||||
assert any(t["type"] == "code_interpreter" for t in agent.definition.tools)
|
||||
|
||||
|
||||
async def test_azure_assistant_agent_with_inputs_outputs_template(
|
||||
azure_openai_unit_test_env, mock_azure_openai_client_and_definition
|
||||
):
|
||||
spec = """
|
||||
type: azure_assistant
|
||||
name: StoryAgent
|
||||
model:
|
||||
id: ${AzureOpenAI:ChatModelId}
|
||||
connection:
|
||||
api_key: ${AzureOpenAI:ApiKey}
|
||||
inputs:
|
||||
topic:
|
||||
description: The story topic.
|
||||
required: true
|
||||
default: AI
|
||||
length:
|
||||
description: The length of story.
|
||||
required: true
|
||||
default: 2
|
||||
outputs:
|
||||
output1:
|
||||
description: The story.
|
||||
template:
|
||||
format: semantic-kernel
|
||||
"""
|
||||
client, definition = mock_azure_openai_client_and_definition
|
||||
definition.name = "StoryAgent"
|
||||
agent: AzureAssistantAgent = await AgentRegistry.create_from_yaml(spec, client=client)
|
||||
assert agent.name == "StoryAgent"
|
||||
assert agent.prompt_template.prompt_template_config.template_format == "semantic-kernel"
|
||||
|
||||
|
||||
async def test_azure_assistant_agent_from_dict_missing_type():
|
||||
data = {"name": "NoType"}
|
||||
with pytest.raises(AgentInitializationException, match="Missing 'type'"):
|
||||
await AgentRegistry.create_from_dict(data)
|
||||
|
||||
|
||||
async def test_azure_assistant_agent_from_yaml_missing_required_fields():
|
||||
spec = """
|
||||
type: azure_assistant
|
||||
"""
|
||||
with pytest.raises(AgentInitializationException):
|
||||
await AgentRegistry.create_from_yaml(spec)
|
||||
|
||||
|
||||
async def test_agent_from_file_success(tmp_path, azure_openai_unit_test_env, mock_azure_openai_client_and_definition):
|
||||
file_path = tmp_path / "spec.yaml"
|
||||
file_path.write_text(
|
||||
"""
|
||||
type: azure_assistant
|
||||
name: DeclarativeAgent
|
||||
model:
|
||||
id: ${AzureOpenAI:ChatModelId}
|
||||
connection:
|
||||
api_key: ${AzureOpenAI:ApiKey}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
client, _ = mock_azure_openai_client_and_definition
|
||||
agent = await AgentRegistry.create_from_file(str(file_path), client=client)
|
||||
assert agent.name == "DeclarativeAgent"
|
||||
assert isinstance(agent, AzureAssistantAgent)
|
||||
|
||||
|
||||
async def test_azure_assistant_agent_from_yaml_invalid_type():
|
||||
spec = """
|
||||
type: not_registered
|
||||
name: ShouldFail
|
||||
"""
|
||||
with pytest.raises(AgentInitializationException, match="not registered"):
|
||||
await AgentRegistry.create_from_yaml(spec)
|
||||
@@ -0,0 +1,334 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from openai import AsyncOpenAI
|
||||
from openai.types.beta.assistant import Assistant, ToolResources, ToolResourcesCodeInterpreter, ToolResourcesFileSearch
|
||||
from openai.types.beta.threads.file_citation_annotation import FileCitation, FileCitationAnnotation
|
||||
from openai.types.beta.threads.file_path_annotation import FilePath, FilePathAnnotation
|
||||
from openai.types.beta.threads.image_file import ImageFile
|
||||
from openai.types.beta.threads.image_file_content_block import ImageFileContentBlock
|
||||
from openai.types.beta.threads.text import Text
|
||||
from openai.types.beta.threads.text_content_block import TextContentBlock
|
||||
|
||||
from semantic_kernel.agents.chat_completion.chat_completion_agent import ChatCompletionAgent
|
||||
from semantic_kernel.agents.open_ai.openai_assistant_agent import OpenAIAssistantAgent
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
from semantic_kernel.contents.text_content import TextContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentChatException
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.kernel import Kernel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_thread_messages():
|
||||
class MockMessage:
|
||||
def __init__(self, role, content, assistant_id=None):
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.assistant_id = assistant_id
|
||||
|
||||
return [
|
||||
MockMessage(
|
||||
role="user",
|
||||
content=[
|
||||
TextContentBlock(
|
||||
type="text",
|
||||
text=Text(
|
||||
value="Hello",
|
||||
annotations=[
|
||||
FilePathAnnotation(
|
||||
type="file_path",
|
||||
file_path=FilePath(file_id="test_file_id"),
|
||||
end_index=5,
|
||||
start_index=0,
|
||||
text="Hello",
|
||||
),
|
||||
FileCitationAnnotation(
|
||||
type="file_citation",
|
||||
file_citation=FileCitation(file_id="test_file_id"),
|
||||
text="Hello",
|
||||
start_index=0,
|
||||
end_index=5,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
MockMessage(
|
||||
role="assistant",
|
||||
content=[
|
||||
ImageFileContentBlock(type="image_file", image_file=ImageFile(file_id="test_file_id", detail="auto"))
|
||||
],
|
||||
assistant_id="assistant_1",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_assistant():
|
||||
return Assistant(
|
||||
created_at=123456789,
|
||||
object="assistant",
|
||||
metadata={
|
||||
"__run_options": json.dumps({
|
||||
"max_completion_tokens": 100,
|
||||
"max_prompt_tokens": 50,
|
||||
"parallel_tool_calls_enabled": True,
|
||||
"truncation_message_count": 10,
|
||||
})
|
||||
},
|
||||
model="test_model",
|
||||
description="test_description",
|
||||
id="test_id",
|
||||
instructions="test_instructions",
|
||||
name="test_name",
|
||||
tools=[{"type": "code_interpreter"}, {"type": "file_search"}], # type: ignore
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
response_format={"type": "json_object"}, # type: ignore
|
||||
tool_resources=ToolResources(
|
||||
code_interpreter=ToolResourcesCodeInterpreter(file_ids=["file1", "file2"]),
|
||||
file_search=ToolResourcesFileSearch(vector_store_ids=["vector_store1"]),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_receive_messages():
|
||||
from semantic_kernel.agents.channels.open_ai_assistant_channel import OpenAIAssistantChannel
|
||||
|
||||
client = MagicMock(spec=AsyncOpenAI)
|
||||
client.beta = AsyncMock()
|
||||
thread_id = "test_thread"
|
||||
channel = OpenAIAssistantChannel(client=client, thread_id=thread_id)
|
||||
history = [
|
||||
MagicMock(spec=ChatMessageContent, role=AuthorRole.USER, items=[TextContent(text="test")]) for _ in range(3)
|
||||
]
|
||||
|
||||
with patch("semantic_kernel.agents.open_ai.assistant_content_generation.create_chat_message"):
|
||||
await channel.receive(history) # type: ignore
|
||||
|
||||
|
||||
async def test_invoke_agent():
|
||||
from semantic_kernel.agents.channels.open_ai_assistant_channel import OpenAIAssistantChannel
|
||||
|
||||
client = AsyncMock(spec=AsyncOpenAI)
|
||||
definition = AsyncMock(spec=Assistant)
|
||||
definition.id = "agent123"
|
||||
definition.name = "agentName"
|
||||
definition.description = "desc"
|
||||
definition.instructions = "test agent"
|
||||
agent = OpenAIAssistantAgent(
|
||||
client=client,
|
||||
definition=definition,
|
||||
arguments=KernelArguments(test="test"),
|
||||
kernel=AsyncMock(spec=Kernel),
|
||||
)
|
||||
|
||||
channel = OpenAIAssistantChannel(client=client, thread_id="test_thread_id")
|
||||
|
||||
async def mock_invoke_internal(*args, **kwargs):
|
||||
for _ in range(3):
|
||||
yield True, MagicMock(spec=ChatMessageContent)
|
||||
|
||||
results = []
|
||||
with patch(
|
||||
"semantic_kernel.agents.channels.open_ai_assistant_channel.AssistantThreadActions.invoke",
|
||||
side_effect=mock_invoke_internal,
|
||||
):
|
||||
async for is_visible, message in channel.invoke(agent):
|
||||
results.append((is_visible, message))
|
||||
|
||||
assert len(results) == 3
|
||||
for is_visible, message in results:
|
||||
assert is_visible is True
|
||||
assert isinstance(message, ChatMessageContent)
|
||||
|
||||
|
||||
async def test_invoke_agent_invalid_instance_throws():
|
||||
from semantic_kernel.agents.channels.open_ai_assistant_channel import OpenAIAssistantChannel
|
||||
|
||||
client = MagicMock(spec=AsyncOpenAI)
|
||||
thread_id = "test_thread"
|
||||
agent = MagicMock(spec=ChatCompletionAgent)
|
||||
agent._is_deleted = False
|
||||
channel = OpenAIAssistantChannel(client=client, thread_id=thread_id)
|
||||
|
||||
with pytest.raises(AgentChatException, match=f"Agent is not of the expected type {type(OpenAIAssistantAgent)}."):
|
||||
async for _, _ in channel.invoke(agent):
|
||||
pass
|
||||
|
||||
|
||||
async def test_invoke_streaming_agent():
|
||||
from semantic_kernel.agents.channels.open_ai_assistant_channel import OpenAIAssistantChannel
|
||||
|
||||
client = AsyncMock(spec=AsyncOpenAI)
|
||||
definition = AsyncMock(spec=Assistant)
|
||||
definition.id = "agent123"
|
||||
definition.name = "agentName"
|
||||
definition.description = "desc"
|
||||
definition.instructions = "test agent"
|
||||
agent = OpenAIAssistantAgent(
|
||||
client=client,
|
||||
definition=definition,
|
||||
arguments=KernelArguments(test="test"),
|
||||
kernel=AsyncMock(spec=Kernel),
|
||||
)
|
||||
|
||||
channel = OpenAIAssistantChannel(client=client, thread_id="test_thread_id")
|
||||
|
||||
results = []
|
||||
|
||||
async def mock_invoke_internal(*args, **kwargs):
|
||||
for _ in range(3):
|
||||
msg = MagicMock(spec=ChatMessageContent)
|
||||
yield msg
|
||||
results.append(msg)
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.channels.open_ai_assistant_channel.AssistantThreadActions.invoke_stream",
|
||||
side_effect=mock_invoke_internal,
|
||||
):
|
||||
async for message in channel.invoke_stream(agent, results):
|
||||
assert message is not None
|
||||
|
||||
assert len(results) == 3
|
||||
for message in results:
|
||||
assert isinstance(message, ChatMessageContent)
|
||||
|
||||
|
||||
async def test_invoke_streaming_agent_invalid_instance_throws():
|
||||
from semantic_kernel.agents.channels.open_ai_assistant_channel import OpenAIAssistantChannel
|
||||
|
||||
client = MagicMock(spec=AsyncOpenAI)
|
||||
thread_id = "test_thread"
|
||||
agent = MagicMock(spec=ChatCompletionAgent)
|
||||
agent._is_deleted = False
|
||||
channel = OpenAIAssistantChannel(client=client, thread_id=thread_id)
|
||||
|
||||
with pytest.raises(AgentChatException, match=f"Agent is not of the expected type {type(OpenAIAssistantAgent)}."):
|
||||
async for _ in channel.invoke_stream(agent, []):
|
||||
pass
|
||||
|
||||
|
||||
async def test_invoke_agent_wrong_type():
|
||||
from semantic_kernel.agents.channels.open_ai_assistant_channel import OpenAIAssistantChannel
|
||||
|
||||
client = MagicMock(spec=AsyncOpenAI)
|
||||
thread_id = "test_thread"
|
||||
agent = MagicMock()
|
||||
channel = OpenAIAssistantChannel(client=client, thread_id=thread_id)
|
||||
|
||||
with pytest.raises(AgentChatException, match="Agent is not of the expected type"):
|
||||
async for _ in channel.invoke(agent):
|
||||
pass
|
||||
|
||||
|
||||
async def test_get_history(mock_thread_messages, mock_assistant, openai_unit_test_env):
|
||||
from semantic_kernel.agents.channels.open_ai_assistant_channel import OpenAIAssistantChannel
|
||||
|
||||
async def mock_list_messages(*args, **kwargs) -> Any:
|
||||
return MagicMock(data=mock_thread_messages)
|
||||
|
||||
async def mock_retrieve_assistant(*args, **kwargs) -> Any:
|
||||
return mock_assistant
|
||||
|
||||
mock_client = MagicMock(spec=AsyncOpenAI)
|
||||
mock_client.beta = MagicMock()
|
||||
mock_client.beta.threads = MagicMock()
|
||||
mock_client.beta.threads.messages = MagicMock()
|
||||
mock_client.beta.threads.messages.list = AsyncMock(side_effect=mock_list_messages)
|
||||
mock_client.beta.assistants = MagicMock()
|
||||
mock_client.beta.assistants.retrieve = AsyncMock(side_effect=mock_retrieve_assistant)
|
||||
|
||||
thread_id = "test_thread"
|
||||
channel = OpenAIAssistantChannel(client=mock_client, thread_id=thread_id)
|
||||
|
||||
results = []
|
||||
async for content in channel.get_history():
|
||||
results.append(content)
|
||||
|
||||
assert len(results) == 2
|
||||
mock_client.beta.threads.messages.list.assert_awaited_once_with(thread_id=thread_id, limit=100, order="desc")
|
||||
|
||||
|
||||
async def test_reset_channel(mock_thread_messages, mock_assistant, openai_unit_test_env):
|
||||
from semantic_kernel.agents.channels.open_ai_assistant_channel import OpenAIAssistantChannel
|
||||
|
||||
async def mock_list_messages(*args, **kwargs) -> Any:
|
||||
return MagicMock(data=mock_thread_messages)
|
||||
|
||||
async def mock_retrieve_assistant(*args, **kwargs) -> Any:
|
||||
return mock_assistant
|
||||
|
||||
mock_client = MagicMock(spec=AsyncOpenAI)
|
||||
mock_client.beta = MagicMock()
|
||||
mock_client.beta.threads = MagicMock()
|
||||
mock_client.beta.threads.messages = MagicMock()
|
||||
mock_client.beta.threads.messages.list = AsyncMock(side_effect=mock_list_messages)
|
||||
mock_client.beta.assistants = MagicMock()
|
||||
mock_client.beta.assistants.retrieve = AsyncMock(side_effect=mock_retrieve_assistant)
|
||||
mock_client.beta.threads.delete = AsyncMock()
|
||||
|
||||
thread_id = "test_thread"
|
||||
channel = OpenAIAssistantChannel(client=mock_client, thread_id=thread_id)
|
||||
|
||||
results = []
|
||||
async for content in channel.get_history():
|
||||
results.append(content)
|
||||
|
||||
assert len(results) == 2
|
||||
mock_client.beta.threads.messages.list.assert_awaited_once_with(thread_id=thread_id, limit=100, order="desc")
|
||||
|
||||
await channel.reset()
|
||||
|
||||
assert channel.thread_id is not None
|
||||
|
||||
|
||||
async def test_reset_channel_error_throws_exception(mock_thread_messages, mock_assistant, openai_unit_test_env):
|
||||
from semantic_kernel.agents.channels.open_ai_assistant_channel import OpenAIAssistantChannel
|
||||
|
||||
async def mock_list_messages(*args, **kwargs) -> Any:
|
||||
return MagicMock(data=mock_thread_messages)
|
||||
|
||||
async def mock_retrieve_assistant(*args, **kwargs) -> Any:
|
||||
return mock_assistant
|
||||
|
||||
mock_client = MagicMock(spec=AsyncOpenAI)
|
||||
mock_client.beta = MagicMock()
|
||||
mock_client.beta.threads = MagicMock()
|
||||
mock_client.beta.threads.messages = MagicMock()
|
||||
mock_client.beta.threads.messages.list = AsyncMock(side_effect=mock_list_messages)
|
||||
mock_client.beta.assistants = MagicMock()
|
||||
mock_client.beta.assistants.retrieve = AsyncMock(side_effect=mock_retrieve_assistant)
|
||||
mock_client.beta.threads.delete = AsyncMock(side_effect=Exception("Test error"))
|
||||
|
||||
thread_id = "test_thread"
|
||||
channel = OpenAIAssistantChannel(client=mock_client, thread_id=thread_id)
|
||||
|
||||
results = []
|
||||
async for content in channel.get_history():
|
||||
results.append(content)
|
||||
|
||||
assert len(results) == 2
|
||||
mock_client.beta.threads.messages.list.assert_awaited_once_with(thread_id=thread_id, limit=100, order="desc")
|
||||
|
||||
with pytest.raises(Exception, match="Test error"):
|
||||
await channel.reset()
|
||||
|
||||
|
||||
async def test_channel_receive_fcc_skipped(openai_unit_test_env):
|
||||
from semantic_kernel.agents.channels.open_ai_assistant_channel import OpenAIAssistantChannel
|
||||
|
||||
message = ChatMessageContent(role=AuthorRole.ASSISTANT, items=[FunctionCallContent(function_name="test_function")])
|
||||
|
||||
client = MagicMock(spec=AsyncOpenAI)
|
||||
|
||||
channel = OpenAIAssistantChannel(client=client, thread_id="test_thread")
|
||||
|
||||
await channel.receive([message])
|
||||
@@ -0,0 +1,579 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from openai import AsyncOpenAI
|
||||
from openai.types.beta.assistant import Assistant
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from semantic_kernel.agents import AgentRegistry, AgentResponseItem, OpenAIAssistantAgent
|
||||
from semantic_kernel.agents.open_ai.openai_assistant_agent import AssistantAgentThread
|
||||
from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
from semantic_kernel.contents.function_result_content import FunctionResultContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentInitializationException, AgentInvokeException
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
from semantic_kernel.functions.kernel_plugin import KernelPlugin
|
||||
from semantic_kernel.kernel import Kernel
|
||||
from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_openai_client_and_definition():
|
||||
client = AsyncMock(spec=AsyncOpenAI)
|
||||
client.beta = MagicMock()
|
||||
client.beta.assistants = MagicMock()
|
||||
|
||||
definition = AsyncMock(spec=Assistant)
|
||||
definition.id = "agent123"
|
||||
definition.name = "DeclarativeAgent"
|
||||
definition.description = "desc"
|
||||
definition.instructions = "test agent"
|
||||
definition.tools = []
|
||||
definition.model = "gpt-4o"
|
||||
definition.temperature = 1.0
|
||||
definition.top_p = 1.0
|
||||
definition.metadata = {}
|
||||
|
||||
client.beta.assistants.create = AsyncMock(return_value=definition)
|
||||
|
||||
return client, definition
|
||||
|
||||
|
||||
class SamplePlugin:
|
||||
@kernel_function
|
||||
def test_plugin(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class ResponseModelPydantic(BaseModel):
|
||||
response: str
|
||||
items: list[str]
|
||||
|
||||
|
||||
class ResponseModelNonPydantic:
|
||||
response: str
|
||||
items: list[str]
|
||||
|
||||
|
||||
async def test_open_ai_assistant_agent_init(openai_client, assistant_definition):
|
||||
sample_prompt_template_config = PromptTemplateConfig(
|
||||
template="template",
|
||||
)
|
||||
|
||||
kernel_plugin = KernelPlugin(name="expected_plugin_name", description="expected_plugin_description")
|
||||
|
||||
agent = OpenAIAssistantAgent(
|
||||
client=AsyncMock(spec=AsyncOpenAI),
|
||||
definition=assistant_definition,
|
||||
arguments=KernelArguments(test="test"),
|
||||
kernel=AsyncMock(spec=Kernel),
|
||||
plugins=[SamplePlugin(), kernel_plugin],
|
||||
polling_options=AsyncMock(spec=RunPollingOptions),
|
||||
prompt_template_config=sample_prompt_template_config,
|
||||
other_arg="test",
|
||||
)
|
||||
assert agent.id == "agent123"
|
||||
assert agent.name == "agentName"
|
||||
assert agent.description == "desc"
|
||||
|
||||
|
||||
def test_open_ai_settings_create_throws(openai_unit_test_env):
|
||||
with patch(
|
||||
"semantic_kernel.connectors.ai.open_ai.settings.open_ai_settings.OpenAISettings.__init__"
|
||||
) as mock_create:
|
||||
mock_create.side_effect = ValidationError.from_exception_data("test", line_errors=[], input_type="python")
|
||||
|
||||
with pytest.raises(AgentInitializationException, match="Failed to create OpenAI settings."):
|
||||
_, _ = OpenAIAssistantAgent.setup_resources(api_key="test_api_key")
|
||||
|
||||
|
||||
def test_open_ai_assistant_with_code_interpreter_tool():
|
||||
tools, resources = OpenAIAssistantAgent.configure_code_interpreter_tool(file_ids=["file_id"])
|
||||
assert tools is not None
|
||||
assert resources is not None
|
||||
|
||||
|
||||
def test_open_ai_assistant_with_file_search_tool():
|
||||
tools, resources = OpenAIAssistantAgent.configure_file_search_tool(vector_store_ids=["vector_store_id"])
|
||||
assert tools is not None
|
||||
assert resources is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model, json_schema_expected",
|
||||
[
|
||||
pytest.param(ResponseModelPydantic, True),
|
||||
pytest.param(ResponseModelNonPydantic, True),
|
||||
pytest.param({"type": "json_object"}, False),
|
||||
pytest.param({"type": "json_schema", "json_schema": {"schema": {}}}, False),
|
||||
],
|
||||
)
|
||||
def test_configure_response_format(model, json_schema_expected):
|
||||
response_format = OpenAIAssistantAgent.configure_response_format(model)
|
||||
assert response_format is not None
|
||||
if json_schema_expected:
|
||||
assert response_format["json_schema"] is not None # type: ignore
|
||||
|
||||
|
||||
def test_configure_response_format_unexpected_type():
|
||||
with pytest.raises(AgentInitializationException) as exc_info:
|
||||
OpenAIAssistantAgent.configure_response_format({"type": "invalid_type"})
|
||||
assert "Encountered unexpected response_format type" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_configure_response_format_json_schema_invalid_schema():
|
||||
with pytest.raises(AgentInitializationException) as exc_info:
|
||||
OpenAIAssistantAgent.configure_response_format({"type": "json_schema", "json_schema": "not_a_dict"})
|
||||
assert "If response_format has type 'json_schema'" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_configure_response_format_invalid_input_type():
|
||||
with pytest.raises(AgentInitializationException) as exc_info:
|
||||
OpenAIAssistantAgent.configure_response_format(3) # type: ignore
|
||||
assert "response_format must be a dictionary" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments, include_args",
|
||||
[
|
||||
pytest.param({"extra_args": "extra_args"}, True),
|
||||
pytest.param(None, False),
|
||||
],
|
||||
)
|
||||
async def test_open_ai_assistant_agent_get_response(arguments, include_args, openai_client, assistant_definition):
|
||||
agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition)
|
||||
|
||||
mock_thread = AsyncMock(spec=AssistantAgentThread)
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
kwargs = None
|
||||
if include_args:
|
||||
kwargs = arguments
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.open_ai.assistant_thread_actions.AssistantThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
response = await agent.get_response(messages="test", thread=mock_thread, **(kwargs or {}))
|
||||
|
||||
assert response is not None
|
||||
assert response.message.content == "content"
|
||||
assert response.thread is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments, include_args",
|
||||
[
|
||||
pytest.param({"extra_args": "extra_args"}, True),
|
||||
pytest.param(None, False),
|
||||
],
|
||||
)
|
||||
async def test_open_ai_assistant_agent_get_response_exception(
|
||||
arguments, include_args, openai_client, assistant_definition
|
||||
):
|
||||
agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition)
|
||||
|
||||
mock_thread = AsyncMock(spec=AssistantAgentThread)
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield False, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
kwargs = None
|
||||
if include_args:
|
||||
kwargs = arguments
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.agents.open_ai.assistant_thread_actions.AssistantThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
),
|
||||
pytest.raises(AgentInvokeException),
|
||||
):
|
||||
await agent.get_response(messages="test", thread=mock_thread, **(kwargs or {}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments, include_args",
|
||||
[
|
||||
pytest.param({"extra_args": "extra_args"}, True),
|
||||
pytest.param(None, False),
|
||||
],
|
||||
)
|
||||
async def test_open_ai_assistant_agent_invoke(arguments, include_args, openai_client, assistant_definition):
|
||||
agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition)
|
||||
mock_thread = AsyncMock(spec=AssistantAgentThread)
|
||||
results = []
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
kwargs = None
|
||||
if include_args:
|
||||
kwargs = arguments
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.open_ai.assistant_thread_actions.AssistantThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for item in agent.invoke(messages="test", thread=mock_thread, **(kwargs or {})):
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
async def test_open_ai_assistant_agent_invoke_message_ordering(openai_client, assistant_definition):
|
||||
agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition)
|
||||
mock_thread = AsyncMock(spec=AssistantAgentThread)
|
||||
|
||||
tool_call_msg = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
content="",
|
||||
items=[FunctionCallContent(name="ToolA", arguments="{}")],
|
||||
)
|
||||
tool_result_msg = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
content="",
|
||||
items=[FunctionResultContent(name="ToolA", result="$9.99", id="func_1")],
|
||||
)
|
||||
assistant_msg = ChatMessageContent(role=AuthorRole.ASSISTANT, content="Here is your answer.")
|
||||
|
||||
emitted_callback_messages = []
|
||||
yielded_messages = []
|
||||
|
||||
async def on_intermediate_message(msg: ChatMessageContent):
|
||||
emitted_callback_messages.append(msg)
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield False, tool_call_msg
|
||||
yield False, tool_result_msg
|
||||
yield True, assistant_msg
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.open_ai.assistant_thread_actions.AssistantThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for item in agent.invoke(
|
||||
messages="test", thread=mock_thread, on_intermediate_message=on_intermediate_message
|
||||
):
|
||||
yielded_messages.append(item)
|
||||
|
||||
assert emitted_callback_messages == [tool_call_msg, tool_result_msg]
|
||||
assert yielded_messages == [AgentResponseItem(message=assistant_msg, thread=mock_thread)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments, include_args",
|
||||
[
|
||||
pytest.param({"extra_args": "extra_args"}, True),
|
||||
pytest.param(None, False),
|
||||
],
|
||||
)
|
||||
async def test_open_ai_assistant_agent_invoke_stream(arguments, include_args, openai_client, assistant_definition):
|
||||
agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition)
|
||||
mock_thread = AsyncMock(spec=AssistantAgentThread)
|
||||
results = []
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
kwargs = None
|
||||
if include_args:
|
||||
kwargs = arguments
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.open_ai.assistant_thread_actions.AssistantThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for item in agent.invoke_stream(messages="test", thread=mock_thread, **(kwargs or {})):
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments, include_args",
|
||||
[
|
||||
pytest.param({"extra_args": "extra_args"}, True),
|
||||
pytest.param(None, False),
|
||||
],
|
||||
)
|
||||
async def test_open_ai_assistant_agent_invoke_stream_with_on_new_message_callback(
|
||||
arguments, include_args, openai_client, assistant_definition
|
||||
):
|
||||
agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition)
|
||||
mock_thread = AsyncMock(spec=AssistantAgentThread)
|
||||
results = []
|
||||
|
||||
final_chat_history = ChatHistory()
|
||||
|
||||
async def handle_stream_completion(message: ChatMessageContent) -> None:
|
||||
final_chat_history.add_message(message)
|
||||
|
||||
tool_msg = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT, content="", items=[FunctionCallContent(name="ToolA", arguments="{}")]
|
||||
)
|
||||
|
||||
streamed_msg = StreamingChatMessageContent(role=AuthorRole.ASSISTANT, content="fake content", choice_index=0)
|
||||
|
||||
async def fake_invoke(*args, output_messages=None, **kwargs):
|
||||
if output_messages is not None:
|
||||
output_messages.append(tool_msg)
|
||||
yield streamed_msg
|
||||
|
||||
kwargs = arguments if include_args else {}
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.open_ai.assistant_thread_actions.AssistantThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for item in agent.invoke_stream(
|
||||
messages="test", thread=mock_thread, on_intermediate_message=handle_stream_completion, **kwargs
|
||||
):
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].message.content == "fake content"
|
||||
|
||||
assert len(final_chat_history.messages) == 1
|
||||
assert final_chat_history.messages[0] == tool_msg
|
||||
|
||||
|
||||
def test_open_ai_assistant_agent_get_channel_keys(openai_client, assistant_definition):
|
||||
agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition)
|
||||
keys = list(agent.get_channel_keys())
|
||||
assert len(keys) >= 3
|
||||
|
||||
|
||||
async def test_open_ai_assistant_agent_create_channel(openai_client, assistant_definition):
|
||||
from semantic_kernel.agents.channels.open_ai_assistant_channel import OpenAIAssistantChannel
|
||||
|
||||
agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition)
|
||||
ch = await agent.create_channel()
|
||||
assert isinstance(ch, OpenAIAssistantChannel)
|
||||
assert ch.thread_id == "test_thread_id"
|
||||
|
||||
|
||||
def test_create_openai_client(openai_unit_test_env):
|
||||
client, model = OpenAIAssistantAgent.setup_resources(env_file_path="./", default_headers={"user_agent": "test"})
|
||||
assert client is not None
|
||||
assert client.api_key == "test_api_key"
|
||||
assert model is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True)
|
||||
async def test_open_ai_agent_missing_api_key_throws(kernel, openai_unit_test_env):
|
||||
with pytest.raises(AgentInitializationException, match="The OpenAI API key is required."):
|
||||
_, _ = OpenAIAssistantAgent.setup_resources(env_file_path="./", default_headers={"user_agent": "test"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_CHAT_MODEL_ID"]], indirect=True)
|
||||
async def test_open_ai_agent_missing_chat_deployment_name_throws(kernel, openai_unit_test_env):
|
||||
with pytest.raises(AgentInitializationException, match="The OpenAI model ID is required."):
|
||||
_, _ = OpenAIAssistantAgent.setup_resources(
|
||||
env_file_path="./",
|
||||
api_key="test_api_key",
|
||||
default_headers={"user_agent": "test"},
|
||||
)
|
||||
|
||||
|
||||
async def test_openai_assistant_agent_from_yaml_minimal(openai_unit_test_env, mock_openai_client_and_definition):
|
||||
spec = """
|
||||
type: openai_assistant
|
||||
name: MinimalAgent
|
||||
model:
|
||||
id: ${OpenAI:ChatModelId}
|
||||
connection:
|
||||
api_key: ${OpenAI:ApiKey}
|
||||
"""
|
||||
client, definition = mock_openai_client_and_definition
|
||||
definition.name = "MinimalAgent"
|
||||
agent = await AgentRegistry.create_from_yaml(spec, client=client)
|
||||
assert isinstance(agent, OpenAIAssistantAgent)
|
||||
assert agent.name == "MinimalAgent"
|
||||
assert agent.definition.model == "gpt-4o"
|
||||
|
||||
|
||||
async def test_openai_assistant_agent_with_tools(openai_unit_test_env, mock_openai_client_and_definition):
|
||||
spec = """
|
||||
type: openai_assistant
|
||||
name: CodeAgent
|
||||
description: Uses code interpreter.
|
||||
model:
|
||||
id: ${OpenAI:ChatModelId}
|
||||
connection:
|
||||
api_key: ${OpenAI:ApiKey}
|
||||
tools:
|
||||
- type: code_interpreter
|
||||
options:
|
||||
file_ids:
|
||||
- ${OpenAI:FileId1}
|
||||
"""
|
||||
client, definition = mock_openai_client_and_definition
|
||||
definition.name = "CodeAgent"
|
||||
definition.tools = [{"type": "code_interpreter", "options": {"file_ids": ["file-123"]}}]
|
||||
agent = await AgentRegistry.create_from_yaml(spec, client=client, extras={"OpenAI:FileId1": "file-123"})
|
||||
assert agent.name == "CodeAgent"
|
||||
assert any(t["type"] == "code_interpreter" for t in agent.definition.tools)
|
||||
|
||||
|
||||
async def test_openai_assistant_agent_with_inputs_outputs_template(
|
||||
openai_unit_test_env, mock_openai_client_and_definition
|
||||
):
|
||||
spec = """
|
||||
type: openai_assistant
|
||||
name: StoryAgent
|
||||
model:
|
||||
id: ${OpenAI:ChatModelId}
|
||||
connection:
|
||||
api_key: ${OpenAI:ApiKey}
|
||||
inputs:
|
||||
topic:
|
||||
description: The story topic.
|
||||
required: true
|
||||
default: AI
|
||||
length:
|
||||
description: The length of story.
|
||||
required: true
|
||||
default: 2
|
||||
outputs:
|
||||
output1:
|
||||
description: The story.
|
||||
template:
|
||||
format: semantic-kernel
|
||||
"""
|
||||
client, definition = mock_openai_client_and_definition
|
||||
definition.name = "StoryAgent"
|
||||
agent: OpenAIAssistantAgent = await AgentRegistry.create_from_yaml(spec, client=client)
|
||||
assert agent.name == "StoryAgent"
|
||||
assert agent.prompt_template.prompt_template_config.template_format == "semantic-kernel"
|
||||
|
||||
|
||||
async def test_openai_assistant_agent_from_dict_missing_type():
|
||||
data = {"name": "NoType"}
|
||||
with pytest.raises(AgentInitializationException, match="Missing 'type'"):
|
||||
await AgentRegistry.create_from_dict(data)
|
||||
|
||||
|
||||
async def test_openai_assistant_agent_from_yaml_missing_required_fields():
|
||||
spec = """
|
||||
type: openai_assistant
|
||||
"""
|
||||
with pytest.raises(AgentInitializationException):
|
||||
await AgentRegistry.create_from_yaml(spec)
|
||||
|
||||
|
||||
async def test_agent_from_file_success(tmp_path, openai_unit_test_env, mock_openai_client_and_definition):
|
||||
file_path = tmp_path / "spec.yaml"
|
||||
file_path.write_text(
|
||||
"""
|
||||
type: openai_assistant
|
||||
name: DeclarativeAgent
|
||||
model:
|
||||
id: ${OpenAI:ChatModelId}
|
||||
connection:
|
||||
api_key: ${OpenAI:ApiKey}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
client, _ = mock_openai_client_and_definition
|
||||
agent = await AgentRegistry.create_from_file(str(file_path), client=client)
|
||||
assert agent.name == "DeclarativeAgent"
|
||||
assert isinstance(agent, OpenAIAssistantAgent)
|
||||
|
||||
|
||||
async def test_openai_assistant_agent_from_yaml_invalid_type():
|
||||
spec = """
|
||||
type: not_registered
|
||||
name: ShouldFail
|
||||
"""
|
||||
with pytest.raises(AgentInitializationException, match="not registered"):
|
||||
await AgentRegistry.create_from_yaml(spec)
|
||||
|
||||
|
||||
async def test_openai_assistant_agent_get_response_passes_function_choice_behavior(openai_client, assistant_definition):
|
||||
agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition)
|
||||
thread = AsyncMock(spec=AssistantAgentThread)
|
||||
fcb = FunctionChoiceBehavior.Auto()
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.open_ai.assistant_thread_actions.AssistantThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
await agent.get_response(messages="message", thread=thread, function_choice_behavior=fcb)
|
||||
|
||||
assert captured_kwargs.get("function_choice_behavior") is fcb
|
||||
|
||||
|
||||
async def test_openai_assistant_agent_invoke_passes_function_choice_behavior(openai_client, assistant_definition):
|
||||
agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition)
|
||||
thread = AsyncMock(spec=AssistantAgentThread)
|
||||
fcb = FunctionChoiceBehavior.Auto()
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.open_ai.assistant_thread_actions.AssistantThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for _ in agent.invoke(messages="message", thread=thread, function_choice_behavior=fcb):
|
||||
pass
|
||||
|
||||
assert captured_kwargs.get("function_choice_behavior") is fcb
|
||||
|
||||
|
||||
async def test_openai_assistant_agent_invoke_stream_passes_function_choice_behavior(
|
||||
openai_client, assistant_definition
|
||||
):
|
||||
agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition)
|
||||
thread = AsyncMock(spec=AssistantAgentThread)
|
||||
fcb = FunctionChoiceBehavior.Auto()
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
yield ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.open_ai.assistant_thread_actions.AssistantThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for _ in agent.invoke_stream(messages="message", thread=thread, function_choice_behavior=fcb):
|
||||
pass
|
||||
|
||||
assert captured_kwargs.get("function_choice_behavior") is fcb
|
||||
|
||||
|
||||
async def test_openai_assistant_agent_get_response_no_fcb_passes_none(openai_client, assistant_definition):
|
||||
agent = OpenAIAssistantAgent(client=openai_client, definition=assistant_definition)
|
||||
thread = AsyncMock(spec=AssistantAgentThread)
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.open_ai.assistant_thread_actions.AssistantThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
await agent.get_response(messages="message", thread=thread)
|
||||
|
||||
assert captured_kwargs.get("function_choice_behavior") is None
|
||||
@@ -0,0 +1,366 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from openai import AsyncOpenAI
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from semantic_kernel.agents import AgentRegistry
|
||||
from semantic_kernel.agents.open_ai.openai_responses_agent import OpenAIResponsesAgent, ResponsesAgentThread
|
||||
from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentInitializationException, AgentInvokeException
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
from semantic_kernel.functions.kernel_plugin import KernelPlugin
|
||||
from semantic_kernel.kernel import Kernel
|
||||
from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_openai_client():
|
||||
return AsyncMock(spec=AsyncOpenAI)
|
||||
|
||||
|
||||
class SamplePlugin:
|
||||
@kernel_function
|
||||
def test_plugin(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class ResponseModelPydantic(BaseModel):
|
||||
response: str
|
||||
items: list[str]
|
||||
|
||||
|
||||
class ResponseModelNonPydantic:
|
||||
response: str
|
||||
items: list[str]
|
||||
|
||||
|
||||
async def test_open_ai_assistant_agent_init():
|
||||
sample_prompt_template_config = PromptTemplateConfig(
|
||||
template="template",
|
||||
)
|
||||
|
||||
kernel_plugin = KernelPlugin(name="expected_plugin_name", description="expected_plugin_description")
|
||||
|
||||
agent = OpenAIResponsesAgent(
|
||||
ai_model_id="model_id",
|
||||
id="agent123",
|
||||
name="agentName",
|
||||
description="desc",
|
||||
client=AsyncMock(spec=AsyncOpenAI),
|
||||
arguments=KernelArguments(test="test"),
|
||||
kernel=AsyncMock(spec=Kernel),
|
||||
plugins=[SamplePlugin(), kernel_plugin],
|
||||
polling_options=AsyncMock(spec=RunPollingOptions),
|
||||
prompt_template_config=sample_prompt_template_config,
|
||||
other_arg="test",
|
||||
)
|
||||
assert agent.id == "agent123"
|
||||
assert agent.name == "agentName"
|
||||
assert agent.description == "desc"
|
||||
|
||||
|
||||
def test_open_ai_settings_create_throws(openai_unit_test_env):
|
||||
with patch(
|
||||
"semantic_kernel.connectors.ai.open_ai.settings.open_ai_settings.OpenAISettings.__init__"
|
||||
) as mock_create:
|
||||
mock_create.side_effect = ValidationError.from_exception_data("test", line_errors=[], input_type="python")
|
||||
|
||||
with pytest.raises(AgentInitializationException, match="Failed to create OpenAI settings."):
|
||||
_, _ = OpenAIResponsesAgent.setup_resources(api_key="test_api_key")
|
||||
|
||||
|
||||
def test_open_ai_assistant_with_file_search_tool():
|
||||
tools, resources = OpenAIResponsesAgent.configure_file_search_tool(vector_store_ids=["vector_store_id"])
|
||||
assert tools is not None
|
||||
assert resources is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model, json_schema_expected",
|
||||
[
|
||||
pytest.param(ResponseModelPydantic, True),
|
||||
pytest.param(ResponseModelNonPydantic, True),
|
||||
pytest.param({"type": "json_object"}, False),
|
||||
pytest.param({"type": "json_schema", "json_schema": {"schema": {}}}, False),
|
||||
],
|
||||
)
|
||||
def test_configure_response_format(model, json_schema_expected):
|
||||
response_format = OpenAIResponsesAgent.configure_response_format(model)
|
||||
assert response_format is not None
|
||||
if json_schema_expected:
|
||||
assert response_format["format"]["schema"] is not None # type: ignore
|
||||
|
||||
|
||||
def test_configure_response_format_unexpected_type():
|
||||
with pytest.raises(AgentInitializationException) as exc_info:
|
||||
OpenAIResponsesAgent.configure_response_format({"type": "invalid_type"})
|
||||
assert "Encountered unexpected response_format type" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_configure_response_format_json_schema_invalid_schema():
|
||||
with pytest.raises(AgentInitializationException) as exc_info:
|
||||
OpenAIResponsesAgent.configure_response_format({"type": "json_schema", "json_schema": "not_a_dict"})
|
||||
assert "If response_format has type 'json_schema'" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_configure_response_format_invalid_input_type():
|
||||
with pytest.raises(AgentInitializationException) as exc_info:
|
||||
OpenAIResponsesAgent.configure_response_format(3) # type: ignore
|
||||
assert "response_format must be a dictionary" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments, include_args",
|
||||
[
|
||||
pytest.param({"extra_args": "extra_args"}, True),
|
||||
pytest.param(None, False),
|
||||
],
|
||||
)
|
||||
async def test_openai_responses_agent_get_response(arguments, include_args):
|
||||
agent = OpenAIResponsesAgent(client=AsyncMock(spec=AsyncOpenAI), ai_model_id="model_id")
|
||||
|
||||
mock_thread = AsyncMock(spec=ResponsesAgentThread)
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
kwargs = None
|
||||
if include_args:
|
||||
kwargs = arguments
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.open_ai.responses_agent_thread_actions.ResponsesAgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
response = await agent.get_response(messages="test", thread=mock_thread, **(kwargs or {}))
|
||||
|
||||
assert response is not None
|
||||
assert response.message.content == "content"
|
||||
assert response.thread is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments, include_args",
|
||||
[
|
||||
pytest.param({"extra_args": "extra_args"}, True),
|
||||
pytest.param(None, False),
|
||||
],
|
||||
)
|
||||
async def test_openai_responses_agent_get_response_exception(arguments, include_args):
|
||||
agent = OpenAIResponsesAgent(client=AsyncMock(spec=AsyncOpenAI), ai_model_id="model_id")
|
||||
|
||||
mock_thread = AsyncMock(spec=ResponsesAgentThread)
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield False, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
kwargs = None
|
||||
if include_args:
|
||||
kwargs = arguments
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.agents.open_ai.responses_agent_thread_actions.ResponsesAgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
),
|
||||
pytest.raises(AgentInvokeException),
|
||||
):
|
||||
await agent.get_response(messages="test", thread=mock_thread, **(kwargs or {}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments, include_args",
|
||||
[
|
||||
pytest.param({"extra_args": "extra_args"}, True),
|
||||
pytest.param(None, False),
|
||||
],
|
||||
)
|
||||
async def test_openai_responses_agent_invoke(arguments, include_args):
|
||||
agent = OpenAIResponsesAgent(client=AsyncMock(spec=AsyncOpenAI), ai_model_id="model_id")
|
||||
mock_thread = AsyncMock(spec=ResponsesAgentThread)
|
||||
results = []
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
kwargs = None
|
||||
if include_args:
|
||||
kwargs = arguments
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.open_ai.responses_agent_thread_actions.ResponsesAgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for item in agent.invoke(messages="test", thread=mock_thread, **(kwargs or {})):
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments, include_args",
|
||||
[
|
||||
pytest.param({"extra_args": "extra_args"}, True),
|
||||
pytest.param(None, False),
|
||||
],
|
||||
)
|
||||
async def test_openai_responses_agent_invoke_stream(arguments, include_args):
|
||||
agent = OpenAIResponsesAgent(client=AsyncMock(spec=AsyncOpenAI), ai_model_id="model_id")
|
||||
mock_thread = AsyncMock(spec=ResponsesAgentThread)
|
||||
results = []
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
kwargs = None
|
||||
if include_args:
|
||||
kwargs = arguments
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.open_ai.responses_agent_thread_actions.ResponsesAgentThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for item in agent.invoke_stream(messages="test", thread=mock_thread, **(kwargs or {})):
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
def test_create_openai_client(openai_unit_test_env):
|
||||
client, model = OpenAIResponsesAgent.setup_resources(env_file_path="./", default_headers={"user_agent": "test"})
|
||||
assert client is not None
|
||||
assert client.api_key == "test_api_key"
|
||||
assert model is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True)
|
||||
async def test_open_ai_agent_missing_api_key_throws(kernel, openai_unit_test_env):
|
||||
with pytest.raises(AgentInitializationException, match="The OpenAI API key is required."):
|
||||
_, _ = OpenAIResponsesAgent.setup_resources(env_file_path="./", default_headers={"user_agent": "test"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_RESPONSES_MODEL_ID"]], indirect=True)
|
||||
async def test_open_ai_agent_missing_chat_deployment_name_throws(kernel, openai_unit_test_env):
|
||||
with pytest.raises(AgentInitializationException, match="The OpenAI Responses model ID is required."):
|
||||
_, _ = OpenAIResponsesAgent.setup_resources(
|
||||
env_file_path="./",
|
||||
api_key="test_api_key",
|
||||
default_headers={"user_agent": "test"},
|
||||
)
|
||||
|
||||
|
||||
async def test_openai_assistant_agent_from_yaml_minimal(openai_unit_test_env, mock_openai_client):
|
||||
spec = """
|
||||
type: openai_responses
|
||||
name: MinimalAgent
|
||||
model:
|
||||
id: ${OpenAI:ChatModelId}
|
||||
connection:
|
||||
api_key: ${OpenAI:ApiKey}
|
||||
"""
|
||||
client = mock_openai_client
|
||||
agent: OpenAIResponsesAgent = await AgentRegistry.create_from_yaml(spec, client=client)
|
||||
assert isinstance(agent, OpenAIResponsesAgent)
|
||||
assert agent.name == "MinimalAgent"
|
||||
assert agent.ai_model_id == openai_unit_test_env.get("OPENAI_RESPONSES_MODEL_ID")
|
||||
|
||||
|
||||
async def test_openai_assistant_agent_with_tools(openai_unit_test_env, mock_openai_client):
|
||||
spec = """
|
||||
type: openai_responses
|
||||
name: FileSearchAgent
|
||||
description: Uses file search.
|
||||
model:
|
||||
id: ${OpenAI:ChatModelId}
|
||||
connection:
|
||||
api_key: ${OpenAI:ApiKey}
|
||||
tools:
|
||||
- type: file_search
|
||||
description: File search for document retrieval.
|
||||
options:
|
||||
vector_store_ids:
|
||||
- ${OpenAI:VectorStoreId}
|
||||
"""
|
||||
client = mock_openai_client
|
||||
agent: OpenAIResponsesAgent = await AgentRegistry.create_from_yaml(
|
||||
spec, client=client, extras={"OpenAI:VectorStoreId": "vector-store-123"}
|
||||
)
|
||||
assert agent.name == "FileSearchAgent"
|
||||
assert any(t["type"] == "file_search" for t in agent.tools)
|
||||
|
||||
|
||||
async def test_openai_assistant_agent_with_inputs_outputs_template(openai_unit_test_env, mock_openai_client):
|
||||
spec = """
|
||||
type: openai_responses
|
||||
name: StoryAgent
|
||||
model:
|
||||
id: ${OpenAI:ChatModelId}
|
||||
connection:
|
||||
api_key: ${OpenAI:ApiKey}
|
||||
inputs:
|
||||
topic:
|
||||
description: The story topic.
|
||||
required: true
|
||||
default: AI
|
||||
length:
|
||||
description: The length of story.
|
||||
required: true
|
||||
default: 2
|
||||
outputs:
|
||||
output1:
|
||||
description: The story.
|
||||
template:
|
||||
format: semantic-kernel
|
||||
"""
|
||||
client = mock_openai_client
|
||||
agent: OpenAIResponsesAgent = await AgentRegistry.create_from_yaml(spec, client=client)
|
||||
assert agent.name == "StoryAgent"
|
||||
assert agent.prompt_template.prompt_template_config.template_format == "semantic-kernel"
|
||||
|
||||
|
||||
async def test_openai_assistant_agent_from_dict_missing_type():
|
||||
data = {"name": "NoType"}
|
||||
with pytest.raises(AgentInitializationException, match="Missing 'type'"):
|
||||
await AgentRegistry.create_from_dict(data)
|
||||
|
||||
|
||||
async def test_openai_assistant_agent_from_yaml_missing_required_fields():
|
||||
spec = """
|
||||
type: openai_responses
|
||||
"""
|
||||
with pytest.raises(AgentInitializationException):
|
||||
await AgentRegistry.create_from_yaml(spec)
|
||||
|
||||
|
||||
async def test_agent_from_file_success(tmp_path, openai_unit_test_env, mock_openai_client):
|
||||
file_path = tmp_path / "spec.yaml"
|
||||
file_path.write_text(
|
||||
"""
|
||||
type: openai_responses
|
||||
name: DeclarativeAgent
|
||||
model:
|
||||
id: ${OpenAI:ChatModelId}
|
||||
connection:
|
||||
api_key: ${OpenAI:ApiKey}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
client = mock_openai_client
|
||||
agent: OpenAIResponsesAgent = await AgentRegistry.create_from_file(str(file_path), client=client)
|
||||
assert agent.name == "DeclarativeAgent"
|
||||
assert isinstance(agent, OpenAIResponsesAgent)
|
||||
|
||||
|
||||
async def test_openai_assistant_agent_from_yaml_invalid_type():
|
||||
spec = """
|
||||
type: not_registered
|
||||
name: ShouldFail
|
||||
"""
|
||||
with pytest.raises(AgentInitializationException, match="not registered"):
|
||||
await AgentRegistry.create_from_yaml(spec)
|
||||
@@ -0,0 +1,568 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from openai._streaming import AsyncStream
|
||||
from openai.types.responses import ResponseFunctionToolCall
|
||||
from openai.types.responses.response import Response
|
||||
from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent
|
||||
from openai.types.responses.response_output_item_done_event import ResponseOutputItemDoneEvent
|
||||
from openai.types.responses.response_output_message import ResponseOutputMessage
|
||||
from openai.types.responses.response_output_text import ResponseOutputText
|
||||
from openai.types.responses.response_stream_event import ResponseStreamEvent
|
||||
from openai.types.responses.response_text_delta_event import Logprob, ResponseTextDeltaEvent
|
||||
|
||||
from semantic_kernel.agents.open_ai.openai_responses_agent import OpenAIResponsesAgent
|
||||
from semantic_kernel.agents.open_ai.responses_agent_thread_actions import ResponsesAgentThreadActions
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.streaming_text_content import StreamingTextContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.functions import KernelArguments
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_agent():
|
||||
agent = AsyncMock(spec=OpenAIResponsesAgent)
|
||||
agent.ai_model_id = "test-model-id"
|
||||
agent.name = "test-agent"
|
||||
agent.polling_options = MagicMock()
|
||||
agent.polling_options.default_polling_interval.total_seconds.return_value = 0.0001
|
||||
agent.tools = []
|
||||
agent.text = "auto"
|
||||
agent.temperature = 0.7
|
||||
agent.top_p = 1.0
|
||||
agent.metadata = {}
|
||||
agent.format_instructions = AsyncMock(return_value="base instructions")
|
||||
agent.kernel = MagicMock()
|
||||
agent.polling_options.run_polling_timeout.total_seconds.return_value = 5
|
||||
agent.polling_options.default_polling_interval.total_seconds.return_value = 1
|
||||
return agent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_response():
|
||||
response = MagicMock(spec=Response)
|
||||
response.status = "completed"
|
||||
response.output = []
|
||||
response.id = "fake-response-id"
|
||||
response.error = None
|
||||
response.incomplete_details = None
|
||||
response.created_at = 10303039393
|
||||
response.usage = None
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_chat_history():
|
||||
history = MagicMock()
|
||||
history.messages = [ChatMessageContent(role=AuthorRole.USER, content="Hello")]
|
||||
return history
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_thread():
|
||||
thread = MagicMock()
|
||||
thread._chat_history.messages = []
|
||||
return thread
|
||||
|
||||
|
||||
async def test_invoke_no_function_calls(mock_agent, mock_response, mock_chat_history, mock_thread):
|
||||
async def mock_get_response(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
with patch.object(ResponsesAgentThreadActions, "_get_response", new=mock_get_response):
|
||||
results = []
|
||||
async for is_visible, msg in ResponsesAgentThreadActions.invoke(
|
||||
agent=mock_agent,
|
||||
chat_history=mock_chat_history,
|
||||
thread=mock_thread,
|
||||
store_enabled=True,
|
||||
function_choice_behavior=MagicMock(),
|
||||
):
|
||||
results.append((is_visible, msg))
|
||||
|
||||
assert len(results) == 1
|
||||
is_visible, final_msg = results[0]
|
||||
assert is_visible is True
|
||||
assert final_msg.role == AuthorRole.ASSISTANT
|
||||
|
||||
|
||||
async def test_invoke_raises_on_failed_response(mock_agent, mock_chat_history, mock_thread):
|
||||
mock_failed_response = MagicMock(spec=Response)
|
||||
mock_failed_response.status = "failed"
|
||||
mock_failed_response.error = MagicMock()
|
||||
mock_failed_response.error.message = "some error"
|
||||
mock_failed_response.incomplete_details = None
|
||||
mock_failed_response.id = "fake-failed-response-id"
|
||||
|
||||
async def mock_get_response(*args, **kwargs):
|
||||
return mock_failed_response
|
||||
|
||||
with (
|
||||
patch.object(ResponsesAgentThreadActions, "_get_response", new=mock_get_response),
|
||||
pytest.raises(Exception, match="Run failed with status: `failed`"),
|
||||
):
|
||||
async for _ in ResponsesAgentThreadActions.invoke(
|
||||
agent=mock_agent,
|
||||
chat_history=mock_chat_history,
|
||||
thread=mock_thread,
|
||||
store_enabled=True,
|
||||
function_choice_behavior=MagicMock(),
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
async def test_invoke_reaches_maximum_attempts(mock_agent, mock_chat_history, mock_thread):
|
||||
call_counter = 0
|
||||
|
||||
response_with_tool_call = MagicMock(spec=Response)
|
||||
response_with_tool_call.status = "completed"
|
||||
response_with_tool_call.id = "fake-response-id"
|
||||
response_with_tool_call.output = [
|
||||
ResponseFunctionToolCall(
|
||||
id="tool_call_id",
|
||||
call_id="call_id",
|
||||
name="test_function",
|
||||
arguments='{"some_arg": 123}',
|
||||
type="function_call",
|
||||
)
|
||||
]
|
||||
response_with_tool_call.error = None
|
||||
response_with_tool_call.incomplete_details = None
|
||||
response_with_tool_call.created_at = 123456
|
||||
response_with_tool_call.usage = None
|
||||
response_with_tool_call.role = "assistant"
|
||||
|
||||
final_response = MagicMock(spec=Response)
|
||||
final_response.status = "completed"
|
||||
final_response.id = "fake-final-response-id"
|
||||
final_response.output = []
|
||||
final_response.error = None
|
||||
final_response.incomplete_details = None
|
||||
final_response.created_at = 123456
|
||||
final_response.usage = None
|
||||
final_response.role = "assistant"
|
||||
|
||||
async def mock_invoke_fc(*args, **kwargs):
|
||||
return MagicMock(terminate=False)
|
||||
|
||||
mock_agent.kernel.invoke_function_call = MagicMock(side_effect=mock_invoke_fc)
|
||||
|
||||
async def mock_get_response(*args, **kwargs):
|
||||
nonlocal call_counter
|
||||
if call_counter < 3:
|
||||
call_counter += 1
|
||||
return response_with_tool_call
|
||||
return final_response
|
||||
|
||||
with patch.object(ResponsesAgentThreadActions, "_get_response", new=mock_get_response):
|
||||
messages = []
|
||||
async for _, msg in ResponsesAgentThreadActions.invoke(
|
||||
agent=mock_agent,
|
||||
chat_history=mock_chat_history,
|
||||
thread=mock_thread,
|
||||
store_enabled=True,
|
||||
function_choice_behavior=MagicMock(maximum_auto_invoke_attempts=3),
|
||||
):
|
||||
messages.append(msg)
|
||||
|
||||
assert messages is not None
|
||||
|
||||
|
||||
async def test_invoke_with_function_calls(mock_agent, mock_chat_history, mock_thread):
|
||||
initial_response = MagicMock(spec=Response)
|
||||
initial_response.status = "completed"
|
||||
initial_response.id = "fake-response-id"
|
||||
initial_response.output = [
|
||||
ResponseFunctionToolCall(
|
||||
id="tool_call_id",
|
||||
call_id="call_id",
|
||||
name="test_function",
|
||||
arguments='{"some_arg": 123}',
|
||||
type="function_call",
|
||||
)
|
||||
]
|
||||
initial_response.error = None
|
||||
initial_response.incomplete_details = None
|
||||
initial_response.created_at = 123456
|
||||
initial_response.usage = None
|
||||
initial_response.role = "assistant"
|
||||
|
||||
final_response = MagicMock(spec=Response)
|
||||
final_response.status = "completed"
|
||||
final_response.id = "fake-final-response-id"
|
||||
final_response.output = []
|
||||
final_response.error = None
|
||||
final_response.incomplete_details = None
|
||||
final_response.created_at = 123456
|
||||
final_response.usage = None
|
||||
final_response.role = "assistant"
|
||||
|
||||
responses = [initial_response, final_response]
|
||||
|
||||
async def mock_invoke_fc(*args, **kwargs):
|
||||
return MagicMock(terminate=False)
|
||||
|
||||
mock_agent.kernel.invoke_function_call = MagicMock(side_effect=mock_invoke_fc)
|
||||
|
||||
async def mock_get_response(*args, **kwargs):
|
||||
return responses.pop(0)
|
||||
|
||||
with patch.object(ResponsesAgentThreadActions, "_get_response", new=mock_get_response):
|
||||
messages = []
|
||||
async for is_visible, msg in ResponsesAgentThreadActions.invoke(
|
||||
agent=mock_agent,
|
||||
chat_history=mock_chat_history,
|
||||
thread=mock_thread,
|
||||
store_enabled=True,
|
||||
function_choice_behavior=MagicMock(maximum_auto_invoke_attempts=1),
|
||||
):
|
||||
messages.append(msg)
|
||||
|
||||
assert len(messages) == 3, f"Expected exactly 3 messages, got {len(messages)}"
|
||||
|
||||
|
||||
async def test_invoke_passes_kernel_arguments_to_kernel(mock_agent, mock_chat_history, mock_thread):
|
||||
# Prepare a response that triggers a function call
|
||||
initial_response = MagicMock(spec=Response)
|
||||
initial_response.status = "completed"
|
||||
initial_response.id = "fake-response-id"
|
||||
initial_response.output = [
|
||||
ResponseFunctionToolCall(
|
||||
id="tool_call_id",
|
||||
call_id="call_id",
|
||||
name="test_function",
|
||||
arguments='{"some_arg": 123}',
|
||||
type="function_call",
|
||||
)
|
||||
]
|
||||
initial_response.error = None
|
||||
initial_response.incomplete_details = None
|
||||
initial_response.created_at = 123456
|
||||
initial_response.usage = None
|
||||
initial_response.role = "assistant"
|
||||
|
||||
final_response = MagicMock(spec=Response)
|
||||
final_response.status = "completed"
|
||||
final_response.id = "fake-final-response-id"
|
||||
final_response.output = []
|
||||
final_response.error = None
|
||||
final_response.incomplete_details = None
|
||||
final_response.created_at = 123456
|
||||
final_response.usage = None
|
||||
final_response.role = "assistant"
|
||||
|
||||
responses = [initial_response, final_response]
|
||||
|
||||
async def mock_invoke_fc(*args, **kwargs):
|
||||
# Assert that KernelArguments were forwarded
|
||||
assert isinstance(kwargs.get("arguments"), KernelArguments)
|
||||
assert kwargs["arguments"].get("foo") == "bar"
|
||||
return MagicMock(terminate=False)
|
||||
|
||||
mock_agent.kernel.invoke_function_call = MagicMock(side_effect=mock_invoke_fc)
|
||||
|
||||
async def mock_get_response(*args, **kwargs):
|
||||
return responses.pop(0)
|
||||
|
||||
with patch.object(ResponsesAgentThreadActions, "_get_response", new=mock_get_response):
|
||||
args = KernelArguments(foo="bar")
|
||||
# Run invoke and ensure no assertion fails inside mock_invoke_fc
|
||||
collected = []
|
||||
async for _, msg in ResponsesAgentThreadActions.invoke(
|
||||
agent=mock_agent,
|
||||
chat_history=mock_chat_history,
|
||||
thread=mock_thread,
|
||||
store_enabled=True,
|
||||
function_choice_behavior=MagicMock(maximum_auto_invoke_attempts=1),
|
||||
arguments=args,
|
||||
):
|
||||
collected.append(msg)
|
||||
assert len(collected) >= 2
|
||||
|
||||
|
||||
async def test_invoke_stream_passes_kernel_arguments_to_kernel(mock_agent, mock_chat_history, mock_thread):
|
||||
class MockStream(AsyncStream[ResponseStreamEvent]):
|
||||
def __init__(self, events):
|
||||
self._events = events
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if not self._events:
|
||||
raise StopAsyncIteration
|
||||
return self._events.pop(0)
|
||||
|
||||
# Event that includes a function call
|
||||
mock_tool_call_event = ResponseOutputItemAddedEvent(
|
||||
item=ResponseFunctionToolCall(
|
||||
id="fake-tool-call-id",
|
||||
call_id="fake-call-id",
|
||||
name="test_function",
|
||||
arguments='{"arg": 123}',
|
||||
type="function_call",
|
||||
),
|
||||
output_index=0,
|
||||
type="response.output_item.added",
|
||||
sequence_number=0,
|
||||
)
|
||||
|
||||
mock_stream_event_end = ResponseOutputItemDoneEvent(
|
||||
item=ResponseOutputMessage(
|
||||
role="assistant",
|
||||
status="completed",
|
||||
id="fake-item-id",
|
||||
content=[ResponseOutputText(text="Final message after tool call", type="output_text", annotations=[])],
|
||||
type="message",
|
||||
),
|
||||
output_index=0,
|
||||
sequence_number=0,
|
||||
type="response.output_item.done",
|
||||
)
|
||||
|
||||
async def mock_get_response(*args, **kwargs):
|
||||
return MockStream([mock_tool_call_event, mock_stream_event_end])
|
||||
|
||||
async def mock_invoke_function_call(*args, **kwargs):
|
||||
assert isinstance(kwargs.get("arguments"), KernelArguments)
|
||||
assert kwargs["arguments"].get("foo") == "bar"
|
||||
return MagicMock(terminate=False)
|
||||
|
||||
mock_agent.kernel.invoke_function_call = MagicMock(side_effect=mock_invoke_function_call)
|
||||
|
||||
with patch.object(ResponsesAgentThreadActions, "_get_response", new=mock_get_response):
|
||||
args = KernelArguments(foo="bar")
|
||||
collected_stream_messages = []
|
||||
async for _ in ResponsesAgentThreadActions.invoke_stream(
|
||||
agent=mock_agent,
|
||||
chat_history=mock_chat_history,
|
||||
thread=mock_thread,
|
||||
store_enabled=True,
|
||||
function_choice_behavior=MagicMock(maximum_auto_invoke_attempts=1),
|
||||
output_messages=collected_stream_messages,
|
||||
arguments=args,
|
||||
):
|
||||
pass
|
||||
# If assertions passed in mock, arguments were forwarded
|
||||
assert len(collected_stream_messages) >= 1
|
||||
|
||||
|
||||
async def test_invoke_stream_no_function_calls(mock_agent, mock_chat_history, mock_thread):
|
||||
class MockStream(AsyncStream[ResponseStreamEvent]):
|
||||
def __init__(self, events):
|
||||
self._events = events
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if not self._events:
|
||||
raise StopAsyncIteration
|
||||
return self._events.pop(0)
|
||||
|
||||
mock_stream_event = ResponseTextDeltaEvent(
|
||||
delta="Test partial content",
|
||||
content_index=0,
|
||||
item_id="fake-item-id",
|
||||
logprobs=[Logprob(token="test_token", logprob=0.3)],
|
||||
output_index=0,
|
||||
type="response.output_text.delta",
|
||||
sequence_number=0,
|
||||
)
|
||||
|
||||
mock_stream_event_end = ResponseOutputItemDoneEvent(
|
||||
item=ResponseOutputMessage(
|
||||
role="assistant",
|
||||
status="completed",
|
||||
id="fake-item-id",
|
||||
content=[ResponseOutputText(text="Test partial content", type="output_text", annotations=[])],
|
||||
type="message",
|
||||
),
|
||||
output_index=0,
|
||||
sequence_number=0,
|
||||
type="response.output_item.done",
|
||||
)
|
||||
|
||||
async def mock_get_response(*args, **kwargs):
|
||||
return MockStream([mock_stream_event, mock_stream_event_end])
|
||||
|
||||
with patch.object(ResponsesAgentThreadActions, "_get_response", new=mock_get_response):
|
||||
collected_stream_messages = []
|
||||
received_text = ""
|
||||
|
||||
async for streaming_msg in ResponsesAgentThreadActions.invoke_stream(
|
||||
agent=mock_agent,
|
||||
chat_history=mock_chat_history,
|
||||
thread=mock_thread,
|
||||
store_enabled=False,
|
||||
function_choice_behavior=MagicMock(),
|
||||
output_messages=collected_stream_messages,
|
||||
):
|
||||
assert isinstance(streaming_msg, StreamingChatMessageContent)
|
||||
for item in streaming_msg.items:
|
||||
if isinstance(item, StreamingTextContent):
|
||||
received_text += item.text
|
||||
|
||||
assert "Test partial content" in received_text, "Expected streamed partial content."
|
||||
assert len(collected_stream_messages) == 1, "Expected exactly one final message."
|
||||
assert collected_stream_messages[0].role == AuthorRole.ASSISTANT
|
||||
|
||||
|
||||
async def test_invoke_stream_with_tool_calls(mock_agent, mock_chat_history, mock_thread):
|
||||
class MockStream(AsyncStream[ResponseStreamEvent]):
|
||||
def __init__(self, events):
|
||||
self._events = events
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if not self._events:
|
||||
raise StopAsyncIteration
|
||||
return self._events.pop(0)
|
||||
|
||||
mock_tool_call_event = ResponseOutputItemAddedEvent(
|
||||
item=ResponseFunctionToolCall(
|
||||
id="fake-tool-call-id",
|
||||
call_id="fake-call-id",
|
||||
name="test_function",
|
||||
arguments='{"arg": 123}',
|
||||
type="function_call",
|
||||
),
|
||||
output_index=0,
|
||||
type="response.output_item.added",
|
||||
sequence_number=0,
|
||||
)
|
||||
|
||||
mock_stream_event_end = ResponseOutputItemDoneEvent(
|
||||
item=ResponseOutputMessage(
|
||||
role="assistant",
|
||||
status="completed",
|
||||
id="fake-item-id",
|
||||
content=[ResponseOutputText(text="Final message after tool call", type="output_text", annotations=[])],
|
||||
type="message",
|
||||
),
|
||||
output_index=0,
|
||||
sequence_number=0,
|
||||
type="response.output_item.done",
|
||||
)
|
||||
|
||||
async def mock_get_response(*args, **kwargs):
|
||||
return MockStream([mock_tool_call_event, mock_stream_event_end])
|
||||
|
||||
async def mock_invoke_function_call(*args, **kwargs):
|
||||
return MagicMock(terminate=False)
|
||||
|
||||
mock_agent.kernel.invoke_function_call = MagicMock(side_effect=mock_invoke_function_call)
|
||||
|
||||
with patch.object(ResponsesAgentThreadActions, "_get_response", new=mock_get_response):
|
||||
collected_stream_messages = []
|
||||
received_text = ""
|
||||
|
||||
async for streaming_msg in ResponsesAgentThreadActions.invoke_stream(
|
||||
agent=mock_agent,
|
||||
chat_history=mock_chat_history,
|
||||
thread=mock_thread,
|
||||
store_enabled=True,
|
||||
function_choice_behavior=MagicMock(maximum_auto_invoke_attempts=1),
|
||||
output_messages=collected_stream_messages,
|
||||
):
|
||||
assert isinstance(streaming_msg, StreamingChatMessageContent)
|
||||
for item in streaming_msg.items:
|
||||
if isinstance(item, StreamingTextContent):
|
||||
received_text += item.text
|
||||
|
||||
assert len(collected_stream_messages) == 2, "Expected exactly two final messages after tool call."
|
||||
assert collected_stream_messages[0].role == AuthorRole.ASSISTANT
|
||||
|
||||
|
||||
def test_get_tools(mock_agent, kernel, custom_plugin_class):
|
||||
kernel.add_plugin(custom_plugin_class)
|
||||
fcb = FunctionChoiceBehavior()
|
||||
tools = ResponsesAgentThreadActions._get_tools(
|
||||
agent=mock_agent,
|
||||
kernel=kernel,
|
||||
function_choice_behavior=fcb,
|
||||
)
|
||||
|
||||
assert len(tools) == len(mock_agent.tools) + len(kernel.get_full_list_of_function_metadata())
|
||||
|
||||
|
||||
def test_prepare_chat_history_multiple_images_no_duplication():
|
||||
"""Test that multiple images in a message don't get duplicated in the request."""
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.contents.image_content import ImageContent
|
||||
from semantic_kernel.contents.text_content import TextContent
|
||||
|
||||
# Create a chat history with a message containing text and multiple images
|
||||
chat_history = ChatHistory()
|
||||
|
||||
message_items = [
|
||||
TextContent(text="How many pictures do you get?"),
|
||||
ImageContent(uri="https://example.com/image1.jpg"),
|
||||
ImageContent(uri="https://example.com/image2.jpg"),
|
||||
ImageContent(uri="https://example.com/image3.jpg"),
|
||||
ImageContent(uri="https://example.com/image4.jpg"),
|
||||
]
|
||||
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
|
||||
message = ChatMessageContent(role=AuthorRole.USER, items=message_items)
|
||||
chat_history.add_message(message)
|
||||
|
||||
# Call the method that was causing duplication
|
||||
result = ResponsesAgentThreadActions._prepare_chat_history_for_request(chat_history, True)
|
||||
|
||||
# Verify we have exactly one message in the result
|
||||
assert len(result) == 1, f"Expected 1 message, got {len(result)}"
|
||||
|
||||
# Get the content from the message
|
||||
message_content = result[0]["content"]
|
||||
|
||||
# Count text and image items
|
||||
text_items = [item for item in message_content if item["type"] == "input_text"]
|
||||
image_items = [item for item in message_content if item["type"] == "input_image"]
|
||||
|
||||
# Verify counts
|
||||
assert len(text_items) == 1, f"Expected 1 text item, got {len(text_items)}"
|
||||
assert len(image_items) == 4, f"Expected 4 image items, got {len(image_items)}"
|
||||
|
||||
# Verify the text content
|
||||
assert text_items[0]["text"] == "How many pictures do you get?"
|
||||
|
||||
# Verify the image URLs are correct and not duplicated
|
||||
expected_urls = [
|
||||
"https://example.com/image1.jpg",
|
||||
"https://example.com/image2.jpg",
|
||||
"https://example.com/image3.jpg",
|
||||
"https://example.com/image4.jpg",
|
||||
]
|
||||
|
||||
actual_urls = [item["image_url"] for item in image_items]
|
||||
assert actual_urls == expected_urls, f"Expected {expected_urls}, got {actual_urls}"
|
||||
|
||||
# Verify total content items equals expected (1 text + 4 images = 5)
|
||||
assert len(message_content) == 5, f"Expected 5 total content items, got {len(message_content)}"
|
||||
@@ -0,0 +1,125 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable
|
||||
|
||||
from semantic_kernel.agents.agent import Agent, AgentResponseItem, AgentThread
|
||||
from semantic_kernel.agents.runtime.core.core_runtime import CoreRuntime
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # pragma: no cover
|
||||
|
||||
|
||||
class MockAgentThread(AgentThread):
|
||||
"""A mock agent thread for testing purposes."""
|
||||
|
||||
@override
|
||||
async def _create(self) -> str:
|
||||
return "mock_thread_id"
|
||||
|
||||
@override
|
||||
async def _delete(self) -> None:
|
||||
pass
|
||||
|
||||
@override
|
||||
async def _on_new_message(self, new_message: ChatMessageContent) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class MockAgent(Agent):
|
||||
"""A mock agent for testing purposes."""
|
||||
|
||||
@override
|
||||
async def get_response(
|
||||
self,
|
||||
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs,
|
||||
) -> AgentResponseItem[ChatMessageContent]:
|
||||
pass
|
||||
|
||||
@override
|
||||
async def invoke(
|
||||
self,
|
||||
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
|
||||
**kwargs,
|
||||
) -> AgentResponseItem[ChatMessageContent]:
|
||||
pass
|
||||
|
||||
@override
|
||||
async def invoke_stream(
|
||||
self,
|
||||
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
|
||||
**kwargs,
|
||||
) -> AsyncIterable[AgentResponseItem[StreamingChatMessageContent]]:
|
||||
"""Simulate streaming response from the agent."""
|
||||
# Simulate some processing time
|
||||
await asyncio.sleep(0.05)
|
||||
yield AgentResponseItem[StreamingChatMessageContent](
|
||||
message=StreamingChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
name=self.name,
|
||||
content="mock",
|
||||
choice_index=0,
|
||||
),
|
||||
thread=thread or MockAgentThread(),
|
||||
)
|
||||
# Simulate some processing time before sending the next part of the response
|
||||
await asyncio.sleep(0.05)
|
||||
yield AgentResponseItem[StreamingChatMessageContent](
|
||||
message=StreamingChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
name=self.name,
|
||||
content="_response",
|
||||
choice_index=0,
|
||||
),
|
||||
thread=thread or MockAgentThread(),
|
||||
)
|
||||
|
||||
|
||||
class MockAgentWithException(MockAgent):
|
||||
"""A mock agent that raises an exception for testing purposes."""
|
||||
|
||||
@override
|
||||
async def invoke_stream(
|
||||
self,
|
||||
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
|
||||
**kwargs,
|
||||
) -> AsyncIterable[AgentResponseItem[StreamingChatMessageContent]]:
|
||||
"""Simulate streaming response from the agent that raises an exception."""
|
||||
# Simulate some processing time
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
yield AgentResponseItem[StreamingChatMessageContent](
|
||||
message=StreamingChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
name=self.name,
|
||||
content="mock",
|
||||
choice_index=0,
|
||||
),
|
||||
thread=thread or MockAgentThread(),
|
||||
)
|
||||
|
||||
raise RuntimeError("Mock agent exception")
|
||||
|
||||
|
||||
class MockRuntime(CoreRuntime):
|
||||
"""A mock agent runtime for testing purposes."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,204 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.orchestration.concurrent import ConcurrentOrchestration
|
||||
from semantic_kernel.agents.orchestration.orchestration_base import DefaultTypeAlias, OrchestrationResult
|
||||
from semantic_kernel.agents.runtime.in_process.in_process_runtime import InProcessRuntime
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from tests.unit.agents.orchestration.conftest import MockAgent, MockAgentWithException, MockRuntime
|
||||
|
||||
|
||||
async def test_prepare():
|
||||
"""Test the prepare method of the ConcurrentOrchestration."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = MockRuntime()
|
||||
|
||||
package_path = "semantic_kernel.agents.orchestration.concurrent"
|
||||
with (
|
||||
patch(f"{package_path}.ConcurrentOrchestration._start"),
|
||||
patch(f"{package_path}.ConcurrentAgentActor.register") as mock_agent_actor_register,
|
||||
patch(f"{package_path}.CollectionActor.register") as mock_collection_actor_register,
|
||||
patch.object(runtime, "add_subscription") as mock_add_subscription,
|
||||
):
|
||||
orchestration = ConcurrentOrchestration(members=[agent_a, agent_b])
|
||||
await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
assert mock_agent_actor_register.call_count == 2
|
||||
assert mock_collection_actor_register.call_count == 1
|
||||
assert mock_add_subscription.call_count == 2
|
||||
|
||||
|
||||
async def test_invoke():
|
||||
"""Test the invoke method of the ConcurrentOrchestration."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = ConcurrentOrchestration(members=[agent_a, agent_b])
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
result = await orchestration_result.get(1.0)
|
||||
|
||||
assert isinstance(orchestration_result, OrchestrationResult)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
assert all(isinstance(item, ChatMessageContent) for item in result)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
async def test_invoke_with_response_callback():
|
||||
"""Test the invoke method of the ConcurrentOrchestration with a response callback."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
responses: list[DefaultTypeAlias] = []
|
||||
try:
|
||||
orchestration = ConcurrentOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
agent_response_callback=lambda x: responses.append(x),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
|
||||
assert len(responses) == 2
|
||||
assert all(isinstance(item, ChatMessageContent) for item in responses)
|
||||
assert all(item.content == "mock_response" for item in responses)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
async def test_invoke_with_streaming_response_callback():
|
||||
"""Test the invoke method of the ConcurrentOrchestration with a streaming response callback."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
responses: dict[str, list[StreamingChatMessageContent]] = {}
|
||||
try:
|
||||
orchestration = ConcurrentOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
streaming_agent_response_callback=lambda x, _: responses.setdefault(x.name, []).append(x),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
|
||||
assert len(responses[agent_a.name]) == 2
|
||||
assert len(responses[agent_b.name]) == 2
|
||||
|
||||
assert all(isinstance(item, StreamingChatMessageContent) for item in responses[agent_a.name])
|
||||
assert all(isinstance(item, StreamingChatMessageContent) for item in responses[agent_b.name])
|
||||
|
||||
agent_a_response = sum(responses[agent_a.name][1:], responses[agent_a.name][0])
|
||||
assert agent_a_response.content == "mock_response"
|
||||
agent_b_response = sum(responses[agent_b.name][1:], responses[agent_b.name][0])
|
||||
assert agent_b_response.content == "mock_response"
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.10 doesn't bound the original function provided to the wraps argument of the patch object.",
|
||||
)
|
||||
async def test_invoke_cancel_before_completion():
|
||||
"""Test the invoke method of the ConcurrentOrchestration with cancellation before completion."""
|
||||
with (
|
||||
patch.object(MockAgent, "invoke_stream", wraps=MockAgent.invoke_stream, autospec=True) as mock_invoke_stream,
|
||||
):
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = ConcurrentOrchestration(members=[agent_a, agent_b])
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
# Cancel before the collection agent gets the responses
|
||||
await asyncio.sleep(0.05)
|
||||
orchestration_result.cancel()
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert mock_invoke_stream.call_count == 2
|
||||
|
||||
|
||||
async def test_invoke_cancel_after_completion():
|
||||
"""Test the invoke method of the ConcurrentOrchestration with cancellation after completion."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = ConcurrentOrchestration(members=[agent_a, agent_b])
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
# Wait for the orchestration to complete
|
||||
await orchestration_result.get(1.0)
|
||||
|
||||
with pytest.raises(RuntimeError, match="The invocation has already been completed."):
|
||||
orchestration_result.cancel()
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
async def test_invoke_with_double_get_result():
|
||||
"""Test the invoke method of the ConcurrentOrchestration with double get result."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = ConcurrentOrchestration(members=[agent_a, agent_b])
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
# Get result before completion
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await orchestration_result.get(0.1)
|
||||
# The invocation should still be in progress and getting the result again should not raise an error
|
||||
result = await orchestration_result.get(1.0)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
async def test_invoke_with_agent_raising_exception():
|
||||
"""Test the invoke method of the ConcurrentOrchestration with an agent raising an exception."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgentWithException()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = ConcurrentOrchestration(members=[agent_a, agent_b])
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Mock agent exception"):
|
||||
await orchestration_result.get(1.0)
|
||||
assert orchestration_result.exception is not None
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
@@ -0,0 +1,399 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.orchestration.group_chat import (
|
||||
BooleanResult,
|
||||
GroupChatOrchestration,
|
||||
RoundRobinGroupChatManager,
|
||||
)
|
||||
from semantic_kernel.agents.orchestration.orchestration_base import DefaultTypeAlias, OrchestrationResult
|
||||
from semantic_kernel.agents.runtime.in_process.in_process_runtime import InProcessRuntime
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from tests.unit.agents.orchestration.conftest import MockAgent, MockAgentWithException, MockRuntime
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # pragma: no cover
|
||||
|
||||
|
||||
class RoundRobinGroupChatManagerWithUserInput(RoundRobinGroupChatManager):
|
||||
@override
|
||||
async def should_request_user_input(self, chat_history: ChatHistory) -> BooleanResult:
|
||||
"""Check if the group chat should request user input."""
|
||||
return BooleanResult(
|
||||
result=True,
|
||||
reason="Allow user input for testing purposes.",
|
||||
)
|
||||
|
||||
|
||||
# region GroupChatOrchestration
|
||||
|
||||
|
||||
async def test_init_member_without_description_throws():
|
||||
"""Test the prepare method of the GroupChatOrchestration with a member without description."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
GroupChatOrchestration(members=[agent_a, agent_b], manager=RoundRobinGroupChatManager())
|
||||
|
||||
|
||||
async def test_prepare():
|
||||
"""Test the prepare method of the GroupChatOrchestration."""
|
||||
agent_a = MockAgent(description="test agent")
|
||||
agent_b = MockAgent(description="test agent")
|
||||
|
||||
runtime = MockRuntime()
|
||||
|
||||
package_path = "semantic_kernel.agents.orchestration.group_chat"
|
||||
with (
|
||||
patch(f"{package_path}.GroupChatOrchestration._start"),
|
||||
patch(f"{package_path}.GroupChatAgentActor.register") as mock_agent_actor_register,
|
||||
patch(f"{package_path}.GroupChatManagerActor.register") as mock_manager_actor_register,
|
||||
patch.object(runtime, "add_subscription") as mock_add_subscription,
|
||||
):
|
||||
orchestration = GroupChatOrchestration(members=[agent_a, agent_b], manager=RoundRobinGroupChatManager())
|
||||
await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
assert mock_agent_actor_register.call_count == 2
|
||||
assert mock_manager_actor_register.call_count == 1
|
||||
assert mock_add_subscription.call_count == 3
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.10 doesn't bound the original function provided to the wraps argument of the patch object.",
|
||||
)
|
||||
async def test_invoke():
|
||||
"""Test the invoke method of the GroupChatOrchestration."""
|
||||
with (
|
||||
patch.object(MockAgent, "invoke_stream", wraps=MockAgent.invoke_stream, autospec=True) as mock_invoke_stream,
|
||||
):
|
||||
agent_a = MockAgent(description="test agent")
|
||||
agent_b = MockAgent(description="test agent")
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = GroupChatOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
manager=RoundRobinGroupChatManager(max_rounds=3),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
result = await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert isinstance(orchestration_result, OrchestrationResult)
|
||||
assert isinstance(result, ChatMessageContent)
|
||||
assert result.role == AuthorRole.ASSISTANT
|
||||
assert result.content == "mock_response"
|
||||
|
||||
assert mock_invoke_stream.call_count == 3
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.10 doesn't bound the original function provided to the wraps argument of the patch object.",
|
||||
)
|
||||
async def test_invoke_with_list():
|
||||
"""Test the invoke method of the GroupChatOrchestration with a list of messages."""
|
||||
with (
|
||||
patch.object(MockAgent, "invoke_stream", wraps=MockAgent.invoke_stream, autospec=True) as mock_invoke_stream,
|
||||
):
|
||||
agent_a = MockAgent(description="test agent")
|
||||
agent_b = MockAgent(description="test agent")
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
messages = [
|
||||
ChatMessageContent(role=AuthorRole.USER, content="test_message_1"),
|
||||
ChatMessageContent(role=AuthorRole.USER, content="test_message_2"),
|
||||
]
|
||||
|
||||
try:
|
||||
orchestration = GroupChatOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
manager=RoundRobinGroupChatManager(max_rounds=2),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task=messages, runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert mock_invoke_stream.call_count == 2
|
||||
# Two messages
|
||||
assert len(mock_invoke_stream.call_args_list[0][0][1]) == 2
|
||||
# Two messages + response from agent A
|
||||
assert len(mock_invoke_stream.call_args_list[1][0][1]) == 3
|
||||
|
||||
|
||||
async def test_invoke_with_response_callback():
|
||||
"""Test the invoke method of the GroupChatOrchestration with a response callback."""
|
||||
agent_a = MockAgent(description="test agent")
|
||||
agent_b = MockAgent(description="test agent")
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
responses: list[DefaultTypeAlias] = []
|
||||
try:
|
||||
orchestration = GroupChatOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
manager=RoundRobinGroupChatManager(max_rounds=3),
|
||||
agent_response_callback=lambda x: responses.append(x),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert len(responses) == 3
|
||||
assert all(isinstance(item, ChatMessageContent) for item in responses)
|
||||
assert all(item.content == "mock_response" for item in responses)
|
||||
|
||||
|
||||
async def test_invoke_with_streaming_response_callback():
|
||||
"""Test the invoke method of the GroupChatOrchestration with a streaming_response callback."""
|
||||
agent_a = MockAgent(description="test agent")
|
||||
agent_b = MockAgent(description="test agent")
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
responses: dict[str, list[StreamingChatMessageContent]] = {}
|
||||
try:
|
||||
orchestration = GroupChatOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
manager=RoundRobinGroupChatManager(max_rounds=3),
|
||||
streaming_agent_response_callback=lambda x, _: responses.setdefault(x.name, []).append(x),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert len(responses[agent_a.name]) == 4 # Invoke twice, each with 2 chunks
|
||||
assert len(responses[agent_b.name]) == 2 # Invoke once, with 2 chunks
|
||||
|
||||
assert all(isinstance(item, StreamingChatMessageContent) for item in responses[agent_a.name])
|
||||
assert all(isinstance(item, StreamingChatMessageContent) for item in responses[agent_b.name])
|
||||
|
||||
agent_a_response = sum(responses[agent_a.name][1:2], responses[agent_a.name][0])
|
||||
assert agent_a_response.content == "mock_response"
|
||||
agent_b_response = sum(responses[agent_b.name][1:], responses[agent_b.name][0])
|
||||
assert agent_b_response.content == "mock_response"
|
||||
|
||||
|
||||
async def test_invoke_with_human_response_function():
|
||||
"""Test the invoke method of the GroupChatOrchestration with a human response function."""
|
||||
agent_a = MockAgent(description="test agent")
|
||||
agent_b = MockAgent(description="test agent")
|
||||
|
||||
user_input_count = 0
|
||||
|
||||
def human_response_function(chat_history: ChatHistory) -> ChatMessageContent:
|
||||
# Simulate user input
|
||||
nonlocal user_input_count
|
||||
user_input_count += 1
|
||||
return ChatMessageContent(
|
||||
role=AuthorRole.USER,
|
||||
content=f"user_input_{user_input_count}",
|
||||
)
|
||||
|
||||
orchestration_manager = RoundRobinGroupChatManagerWithUserInput(
|
||||
max_rounds=3,
|
||||
human_response_function=human_response_function,
|
||||
)
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = GroupChatOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
manager=orchestration_manager,
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert user_input_count == 4 # 3 rounds + 1 initial user input
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="Unreliable test due to timing issues in CI environment. To be fixed later.",
|
||||
)
|
||||
async def test_invoke_cancel_before_completion():
|
||||
"""Test the invoke method of the GroupChatOrchestration with cancellation before completion."""
|
||||
with (
|
||||
patch.object(MockAgent, "invoke_stream", wraps=MockAgent.invoke_stream, autospec=True) as mock_invoke_stream,
|
||||
):
|
||||
agent_a = MockAgent(description="test agent")
|
||||
agent_b = MockAgent(description="test agent")
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = GroupChatOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
manager=RoundRobinGroupChatManager(max_rounds=3),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
# Cancel before the second agent responds
|
||||
await asyncio.sleep(0.19)
|
||||
orchestration_result.cancel()
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert mock_invoke_stream.call_count == 2
|
||||
|
||||
|
||||
async def test_invoke_cancel_after_completion():
|
||||
"""Test the invoke method of the GroupChatOrchestration with cancellation after completion."""
|
||||
agent_a = MockAgent(description="test agent")
|
||||
agent_b = MockAgent(description="test agent")
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = GroupChatOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
manager=RoundRobinGroupChatManager(max_rounds=3),
|
||||
)
|
||||
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
# Wait for the orchestration to complete
|
||||
await orchestration_result.get(1.0)
|
||||
|
||||
with pytest.raises(RuntimeError, match="The invocation has already been completed."):
|
||||
orchestration_result.cancel()
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
async def test_invoke_with_agent_raising_exception():
|
||||
"""Test the invoke method of the GroupChatOrchestration with an agent raising an exception."""
|
||||
agent_a = MockAgent(description="test agent")
|
||||
agent_b = MockAgentWithException(description="test agent")
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = GroupChatOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
manager=RoundRobinGroupChatManager(max_rounds=3),
|
||||
)
|
||||
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Mock agent exception"):
|
||||
await orchestration_result.get(1.0)
|
||||
assert orchestration_result.exception is not None
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
# endregion GroupChatOrchestration
|
||||
|
||||
# region RoundRobinGroupChatManager
|
||||
|
||||
|
||||
def test_round_robin_group_chat_manager_init():
|
||||
"""Test the initialization of the RoundRobinGroupChatManager."""
|
||||
manager = RoundRobinGroupChatManager()
|
||||
assert manager.max_rounds is None
|
||||
assert manager.current_round == 0
|
||||
assert manager.current_index == 0
|
||||
assert manager.human_response_function is None
|
||||
|
||||
|
||||
def test_round_robin_group_chat_manager_init_with_max_rounds():
|
||||
"""Test the initialization of the RoundRobinGroupChatManager with max_rounds."""
|
||||
manager = RoundRobinGroupChatManager(max_rounds=5)
|
||||
assert manager.max_rounds == 5
|
||||
assert manager.current_round == 0
|
||||
assert manager.current_index == 0
|
||||
assert manager.human_response_function is None
|
||||
|
||||
|
||||
def test_round_robin_group_chat_manager_init_with_human_response_function():
|
||||
"""Test the initialization of the RoundRobinGroupChatManager with human_response_function."""
|
||||
|
||||
async def human_response_function(chat_history: ChatHistory) -> str:
|
||||
# Simulate user input
|
||||
await asyncio.sleep(0.1)
|
||||
return "user_input"
|
||||
|
||||
manager = RoundRobinGroupChatManager(human_response_function=human_response_function)
|
||||
assert manager.max_rounds is None
|
||||
assert manager.current_round == 0
|
||||
assert manager.current_index == 0
|
||||
assert manager.human_response_function == human_response_function
|
||||
|
||||
|
||||
async def test_round_robin_group_chat_manager_should_terminate():
|
||||
"""Test the should_terminate method of the RoundRobinGroupChatManager."""
|
||||
manager = RoundRobinGroupChatManager(max_rounds=3)
|
||||
|
||||
result = await manager.should_terminate(ChatHistory())
|
||||
assert result.result is False
|
||||
result = await manager.should_terminate(ChatHistory())
|
||||
assert result.result is False
|
||||
result = await manager.should_terminate(ChatHistory())
|
||||
assert result.result is False
|
||||
result = await manager.should_terminate(ChatHistory())
|
||||
assert result.result is True
|
||||
|
||||
|
||||
async def test_round_robin_group_chat_manager_should_terminate_without_max_rounds():
|
||||
"""Test the should_terminate method of the RoundRobinGroupChatManager without max_rounds."""
|
||||
manager = RoundRobinGroupChatManager()
|
||||
|
||||
result = await manager.should_terminate(ChatHistory())
|
||||
assert result.result is False
|
||||
|
||||
|
||||
async def test_round_robin_group_chat_manager_select_next_agent():
|
||||
"""Test the select_next_agent method of the RoundRobinGroupChatManager."""
|
||||
manager = RoundRobinGroupChatManager(max_rounds=3)
|
||||
|
||||
participant_descriptions = {
|
||||
"agent_1": "Agent 1",
|
||||
"agent_2": "Agent 2",
|
||||
"agent_3": "Agent 3",
|
||||
}
|
||||
|
||||
await manager.should_terminate(ChatHistory())
|
||||
result = await manager.select_next_agent(ChatHistory(), participant_descriptions)
|
||||
assert result.result == "agent_1"
|
||||
|
||||
await manager.should_terminate(ChatHistory())
|
||||
result = await manager.select_next_agent(ChatHistory(), participant_descriptions)
|
||||
assert result.result == "agent_2"
|
||||
|
||||
await manager.should_terminate(ChatHistory())
|
||||
result = await manager.select_next_agent(ChatHistory(), participant_descriptions)
|
||||
assert result.result == "agent_3"
|
||||
|
||||
assert manager.current_round == 3
|
||||
|
||||
|
||||
# endregion RoundRobinGroupChatManager
|
||||
@@ -0,0 +1,721 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.agent import Agent, AgentResponseItem, AgentThread
|
||||
from semantic_kernel.agents.orchestration.handoffs import (
|
||||
HANDOFF_PLUGIN_NAME,
|
||||
HandoffAgentActor,
|
||||
HandoffOrchestration,
|
||||
OrchestrationHandoffs,
|
||||
)
|
||||
from semantic_kernel.agents.orchestration.orchestration_base import DefaultTypeAlias
|
||||
from semantic_kernel.agents.runtime.in_process.in_process_runtime import InProcessRuntime
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
from semantic_kernel.contents.function_result_content import FunctionResultContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.kernel import Kernel
|
||||
from tests.unit.agents.orchestration.conftest import MockAgent, MockAgentWithException, MockRuntime
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # pragma: no cover
|
||||
|
||||
|
||||
class MockAgentWithHandoffFunctionCall(Agent):
|
||||
"""A mock agent with handoff function call for testing purposes."""
|
||||
|
||||
target_agent: Agent
|
||||
|
||||
def __init__(self, target_agent: Agent):
|
||||
super().__init__(target_agent=target_agent)
|
||||
|
||||
@override
|
||||
async def get_response(
|
||||
self,
|
||||
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
kernel: Kernel | None = None,
|
||||
**kwargs,
|
||||
) -> AgentResponseItem[ChatMessageContent]:
|
||||
pass
|
||||
|
||||
@override
|
||||
async def invoke(
|
||||
self,
|
||||
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
|
||||
**kwargs,
|
||||
) -> AgentResponseItem[ChatMessageContent]:
|
||||
pass
|
||||
|
||||
@override
|
||||
async def invoke_stream(
|
||||
self,
|
||||
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
|
||||
kernel: Kernel | None = None,
|
||||
**kwargs,
|
||||
) -> AsyncIterable[AgentResponseItem[StreamingChatMessageContent]]:
|
||||
"""Simulate streaming response from the agent."""
|
||||
function_call = FunctionCallContent(
|
||||
function_name=f"transfer_to_{self.target_agent.name}",
|
||||
plugin_name=HANDOFF_PLUGIN_NAME,
|
||||
call_id="test_call_id",
|
||||
id="test_id",
|
||||
)
|
||||
|
||||
# Simulate some processing time
|
||||
await asyncio.sleep(0.1)
|
||||
await kernel.invoke_function_call(
|
||||
function_call=function_call,
|
||||
chat_history=ChatHistory(),
|
||||
)
|
||||
|
||||
# Do not yield any messages, as the agent doesn't yield any tool related messages from the streaming API.
|
||||
# Nevertheless, the method needs have a `yield` code path to satisfy the AsyncIterable interface.
|
||||
if False:
|
||||
yield
|
||||
|
||||
# Simulate on_intermediate_message callback
|
||||
await on_intermediate_message(
|
||||
StreamingChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
name=self.name,
|
||||
choice_index=0,
|
||||
items=[function_call],
|
||||
)
|
||||
)
|
||||
await on_intermediate_message(
|
||||
StreamingChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
name=self.name,
|
||||
choice_index=0,
|
||||
items=[
|
||||
FunctionResultContent(
|
||||
function_name=function_call.function_name,
|
||||
plugin_name=function_call.plugin_name,
|
||||
call_id=function_call.call_id,
|
||||
id=function_call.id,
|
||||
result=None,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class MockAgentWithCompleteTaskFunctionCall(Agent):
|
||||
"""A mock agent with complete_task function call for testing purposes."""
|
||||
|
||||
@override
|
||||
async def get_response(
|
||||
self,
|
||||
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
kernel: Kernel | None = None,
|
||||
**kwargs,
|
||||
) -> AgentResponseItem[ChatMessageContent]:
|
||||
pass
|
||||
|
||||
@override
|
||||
async def invoke(
|
||||
self,
|
||||
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
|
||||
**kwargs,
|
||||
) -> AgentResponseItem[ChatMessageContent]:
|
||||
pass
|
||||
|
||||
@override
|
||||
async def invoke_stream(
|
||||
self,
|
||||
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
|
||||
kernel: Kernel | None = None,
|
||||
**kwargs,
|
||||
) -> AsyncIterable[AgentResponseItem[StreamingChatMessageContent]]:
|
||||
"""Simulate streaming response from the agent."""
|
||||
# Simulate some processing time
|
||||
await asyncio.sleep(0.1)
|
||||
await kernel.invoke_function_call(
|
||||
function_call=FunctionCallContent(
|
||||
function_name="complete_task",
|
||||
plugin_name=HANDOFF_PLUGIN_NAME,
|
||||
call_id="test_call_id",
|
||||
id="test_id",
|
||||
arguments={"task_summary": "test_summary"},
|
||||
),
|
||||
chat_history=ChatHistory(),
|
||||
)
|
||||
|
||||
# Do not yield any messages, as the agent doesn't yield any tool related messages from the streaming API.
|
||||
# Nevertheless, the method needs have a `yield` code path to satisfy the AsyncIterable interface.
|
||||
if False:
|
||||
yield
|
||||
|
||||
|
||||
# region HandoffOrchestration
|
||||
|
||||
|
||||
def test_init_without_handoffs():
|
||||
"""Test the initialization of HandoffOrchestration without handoffs."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
HandoffOrchestration(members=[agent_a, agent_b], handoffs={})
|
||||
|
||||
|
||||
def test_init_with_invalid_handoff():
|
||||
"""Test the initialization of HandoffOrchestration with invalid handoff."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
# Invalid handoff agent name
|
||||
with pytest.raises(ValueError):
|
||||
HandoffOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
handoffs={
|
||||
agent_a.name: {agent_b.name: "test", "invalid_agent_name": "test"},
|
||||
agent_b.name: {agent_a.name: "test"},
|
||||
},
|
||||
)
|
||||
|
||||
# Invalid handoff agent name (not in members)
|
||||
with pytest.raises(ValueError):
|
||||
HandoffOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
handoffs={
|
||||
"invalid_agent_name": {agent_b.name: "test"},
|
||||
agent_b.name: {agent_a.name: "test"},
|
||||
},
|
||||
)
|
||||
|
||||
# Cannot handoff to self
|
||||
with pytest.raises(ValueError):
|
||||
HandoffOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
handoffs={
|
||||
agent_a.name: {agent_a.name: "test"},
|
||||
agent_b.name: {agent_a.name: "test"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_init_with_duplicate_handoffs():
|
||||
"""Test the initialization of HandoffOrchestration with duplicate handoffs."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
# Uniqueness guarantee
|
||||
orchestration = HandoffOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
handoffs={
|
||||
agent_a.name: {agent_b.name: "test 1", agent_b.name: "test 2"},
|
||||
},
|
||||
)
|
||||
|
||||
assert len(orchestration._handoffs[agent_a.name]) == 1
|
||||
|
||||
|
||||
def test_init_with_dictionary_handoffs():
|
||||
"""Test the initialization of HandoffOrchestration with dictionary handoffs."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
orchestration_handoffs = OrchestrationHandoffs(
|
||||
{
|
||||
agent_a.name: {agent_b.name: "test 1"},
|
||||
agent_b.name: {agent_a.name: "test 2"},
|
||||
},
|
||||
)
|
||||
|
||||
assert len(orchestration_handoffs) == 2
|
||||
for handoff_agent_name, handoff_description in orchestration_handoffs[agent_a.name].items():
|
||||
assert handoff_agent_name == agent_b.name
|
||||
assert handoff_description == "test 1"
|
||||
|
||||
for handoff_agent_name, handoff_description in orchestration_handoffs[agent_b.name].items():
|
||||
assert handoff_agent_name == agent_a.name
|
||||
assert handoff_description == "test 2"
|
||||
|
||||
|
||||
def test_orchestration_handoff_add():
|
||||
"""Test the add method of the OrchestrationHandoffs."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
orchestration_handoffs = OrchestrationHandoffs().add(agent_a, agent_b).add(agent_b, agent_a)
|
||||
|
||||
assert isinstance(orchestration_handoffs, OrchestrationHandoffs)
|
||||
assert len(orchestration_handoffs) == 2
|
||||
assert len(orchestration_handoffs[agent_a.name]) == 1
|
||||
assert len(orchestration_handoffs[agent_b.name]) == 1
|
||||
for handoff_agent_name, handoff_description in orchestration_handoffs[agent_a.name].items():
|
||||
assert handoff_agent_name == agent_b.name
|
||||
assert handoff_description == ""
|
||||
for handoff_agent_name, handoff_description in orchestration_handoffs[agent_b.name].items():
|
||||
assert handoff_agent_name == agent_a.name
|
||||
assert handoff_description == ""
|
||||
|
||||
|
||||
def test_orchestration_handoff_add_many():
|
||||
"""Test the add_many method of the OrchestrationHandoffs."""
|
||||
agent_a = MockAgent(description="agent_a")
|
||||
agent_b = MockAgent(description="agent_b")
|
||||
agent_c = MockAgent(description="agent_c")
|
||||
|
||||
# Case 1: Agent instance as source and dictionary as handoffs
|
||||
orchestration_handoffs = OrchestrationHandoffs().add_many(
|
||||
agent_a,
|
||||
{agent_b.name: "test 1", agent_c.name: "test 2"},
|
||||
)
|
||||
|
||||
assert isinstance(orchestration_handoffs, OrchestrationHandoffs)
|
||||
assert len(orchestration_handoffs) == 1
|
||||
assert len(orchestration_handoffs[agent_a.name]) == 2
|
||||
for handoff_agent_name, handoff_description in orchestration_handoffs[agent_a.name].items():
|
||||
assert handoff_agent_name in [agent_b.name, agent_c.name]
|
||||
assert handoff_description in ["test 1", "test 2"]
|
||||
|
||||
# Case 2: Agent name as source and list of agents as handoffs
|
||||
orchestration_handoffs = OrchestrationHandoffs().add_many(
|
||||
agent_a.name,
|
||||
{agent_b.name: "test 1", agent_c.name: "test 2"},
|
||||
)
|
||||
|
||||
assert isinstance(orchestration_handoffs, OrchestrationHandoffs)
|
||||
assert len(orchestration_handoffs) == 1
|
||||
assert len(orchestration_handoffs[agent_a.name]) == 2
|
||||
for handoff_agent_name, handoff_description in orchestration_handoffs[agent_a.name].items():
|
||||
assert handoff_agent_name in [agent_b.name, agent_c.name]
|
||||
assert handoff_description in ["test 1", "test 2"]
|
||||
|
||||
# Case 3: Agent instance as source and list of agents as handoffs
|
||||
orchestration_handoffs = OrchestrationHandoffs().add_many(agent_a, [agent_b, agent_c])
|
||||
assert isinstance(orchestration_handoffs, OrchestrationHandoffs)
|
||||
assert len(orchestration_handoffs) == 1
|
||||
assert len(orchestration_handoffs[agent_a.name]) == 2
|
||||
for handoff_agent_name, handoff_description in orchestration_handoffs[agent_a.name].items():
|
||||
assert handoff_agent_name in [agent_b.name, agent_c.name]
|
||||
assert handoff_description in [agent_b.description, agent_c.description]
|
||||
|
||||
# Case 4: Agent name as source and list of agent names as handoffs
|
||||
orchestration_handoffs = OrchestrationHandoffs().add_many(agent_a.name, [agent_b.name, agent_c.name])
|
||||
assert isinstance(orchestration_handoffs, OrchestrationHandoffs)
|
||||
assert len(orchestration_handoffs) == 1
|
||||
assert len(orchestration_handoffs[agent_a.name]) == 2
|
||||
for handoff_agent_name, handoff_description in orchestration_handoffs[agent_a.name].items():
|
||||
assert handoff_agent_name in [agent_b.name, agent_c.name]
|
||||
assert handoff_description == ""
|
||||
|
||||
|
||||
async def test_prepare():
|
||||
"""Test the prepare method of the HandoffOrchestration."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
agent_c = MockAgent()
|
||||
|
||||
runtime = MockRuntime()
|
||||
|
||||
package_path = "semantic_kernel.agents.orchestration.handoffs"
|
||||
with (
|
||||
patch(f"{package_path}.HandoffOrchestration._start"),
|
||||
patch(f"{package_path}.HandoffAgentActor.register") as mock_agent_actor_register,
|
||||
patch.object(runtime, "add_subscription") as mock_add_subscription,
|
||||
):
|
||||
orchestration = HandoffOrchestration(
|
||||
members=[agent_a, agent_b, agent_c],
|
||||
handoffs={
|
||||
agent_a.name: {agent_b.name: "test"},
|
||||
agent_b.name: {agent_c.name: "test"},
|
||||
agent_c.name: {agent_a.name: "test"},
|
||||
},
|
||||
)
|
||||
await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
assert mock_agent_actor_register.call_count == 3
|
||||
assert mock_add_subscription.call_count == 3
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.10 doesn't bound the original function provided to the wraps argument of the patch object.",
|
||||
)
|
||||
async def test_invoke():
|
||||
"""Test the prepare method of the HandoffOrchestration."""
|
||||
with (
|
||||
patch.object(HandoffAgentActor, "__init__", wraps=HandoffAgentActor.__init__, autospec=True) as mock_init,
|
||||
patch.object(MockAgent, "invoke_stream", wraps=MockAgent.invoke_stream, autospec=True) as mock_invoke_stream,
|
||||
):
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
agent_c = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = HandoffOrchestration(
|
||||
members=[agent_a, agent_b, agent_c],
|
||||
handoffs={
|
||||
agent_a.name: {agent_b.name: "test", agent_c.name: "test"},
|
||||
agent_b.name: {agent_a.name: "test"},
|
||||
},
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
|
||||
assert mock_init.call_args_list[0][0][3] == {agent_b.name: "test", agent_c.name: "test"}
|
||||
assert isinstance(mock_invoke_stream.call_args_list[0][1]["kernel"], Kernel)
|
||||
kernel = mock_invoke_stream.call_args_list[0][1]["kernel"]
|
||||
assert HANDOFF_PLUGIN_NAME in kernel.plugins
|
||||
assert (
|
||||
len(kernel.plugins[HANDOFF_PLUGIN_NAME].functions) == 3
|
||||
) # two handoff functions + complete task function
|
||||
# The kernel in the agent should not be modified
|
||||
assert len(agent_a.kernel.plugins) == 0
|
||||
assert len(agent_b.kernel.plugins) == 0
|
||||
assert len(agent_c.kernel.plugins) == 0
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.10 doesn't bound the original function provided to the wraps argument of the patch object.",
|
||||
)
|
||||
async def test_invoke_with_list():
|
||||
"""Test the invoke method of the HandoffOrchestration with a list of messages."""
|
||||
with (
|
||||
patch.object(MockAgent, "invoke_stream", wraps=MockAgent.invoke_stream, autospec=True) as mock_invoke_stream,
|
||||
):
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
messages = [
|
||||
ChatMessageContent(role=AuthorRole.USER, content="test_message_1"),
|
||||
ChatMessageContent(role=AuthorRole.USER, content="test_message_2"),
|
||||
]
|
||||
|
||||
try:
|
||||
orchestration = HandoffOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
handoffs={agent_a.name: {agent_b.name: "test"}},
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task=messages, runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert mock_invoke_stream.call_count == 1
|
||||
# Two messages
|
||||
assert len(mock_invoke_stream.call_args_list[0][0][1]) == 2
|
||||
# The kernel in the agent should not be modified
|
||||
assert len(agent_a.kernel.plugins) == 0
|
||||
assert len(agent_b.kernel.plugins) == 0
|
||||
|
||||
|
||||
async def test_invoke_with_response_callback():
|
||||
"""Test the invoke method of the HandoffOrchestration with a response callback."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
responses: list[DefaultTypeAlias] = []
|
||||
try:
|
||||
orchestration = HandoffOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
handoffs={agent_a.name: {agent_b.name: "test"}},
|
||||
agent_response_callback=lambda x: responses.append(x),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert len(responses) == 1
|
||||
assert all(isinstance(item, ChatMessageContent) for item in responses)
|
||||
assert all(item.content == "mock_response" for item in responses)
|
||||
# The kernel in the agent should not be modified
|
||||
assert len(agent_a.kernel.plugins) == 0
|
||||
assert len(agent_b.kernel.plugins) == 0
|
||||
|
||||
|
||||
async def test_invoke_with_streaming_response_callback():
|
||||
"""Test the invoke method of the HandoffOrchestration with a streaming response callback."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
responses: dict[str, list[StreamingChatMessageContent]] = {}
|
||||
try:
|
||||
orchestration = HandoffOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
handoffs={agent_a.name: {agent_b.name: "test"}},
|
||||
streaming_agent_response_callback=lambda x, _: responses.setdefault(x.name, []).append(x),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert len(responses[agent_a.name]) == 2
|
||||
assert all(isinstance(item, StreamingChatMessageContent) for item in responses[agent_a.name])
|
||||
agent_a_response = sum(responses[agent_a.name][1:], responses[agent_a.name][0])
|
||||
assert agent_a_response.content == "mock_response"
|
||||
|
||||
# Agent B was not invoked, so it should not have any responses
|
||||
assert agent_b.name not in responses or len(responses[agent_b.name]) == 0
|
||||
|
||||
# The kernel in the agent should not be modified
|
||||
assert len(agent_a.kernel.plugins) == 0
|
||||
assert len(agent_b.kernel.plugins) == 0
|
||||
|
||||
|
||||
async def test_response_callback_with_handoff_function_call():
|
||||
"""Test the response callback of the HandoffOrchestration with a handoff function call."""
|
||||
agent_b = MockAgent()
|
||||
agent_a = MockAgentWithHandoffFunctionCall(agent_b)
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
responses: list[DefaultTypeAlias] = []
|
||||
|
||||
try:
|
||||
orchestration = HandoffOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
handoffs={agent_a.name: {agent_b.name: "test"}},
|
||||
agent_response_callback=lambda x: responses.append(x),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert len(responses) == 3
|
||||
|
||||
assert responses[0].name == agent_a.name
|
||||
assert isinstance(responses[0].items[0], FunctionCallContent)
|
||||
assert responses[1].name == agent_a.name
|
||||
assert isinstance(responses[1].items[0], FunctionResultContent)
|
||||
|
||||
assert responses[2].name == agent_b.name
|
||||
|
||||
|
||||
async def test_streaming_response_callback_with_handoff_function_call():
|
||||
"""Test the streaming sresponse callback of the HandoffOrchestration with a handoff function call."""
|
||||
agent_b = MockAgent()
|
||||
agent_a = MockAgentWithHandoffFunctionCall(agent_b)
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
responses: dict[str, list[StreamingChatMessageContent]] = {}
|
||||
try:
|
||||
orchestration = HandoffOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
handoffs={agent_a.name: {agent_b.name: "test"}},
|
||||
streaming_agent_response_callback=lambda x, _: responses.setdefault(x.name, []).append(x),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert len(responses[agent_a.name]) == 2
|
||||
assert len(responses[agent_b.name]) == 2 # 2 chunks
|
||||
|
||||
assert responses[agent_a.name][0].name == agent_a.name
|
||||
assert isinstance(responses[agent_a.name][0].items[0], FunctionCallContent)
|
||||
assert responses[agent_a.name][1].name == agent_a.name
|
||||
assert isinstance(responses[agent_a.name][1].items[0], FunctionResultContent)
|
||||
|
||||
assert responses[agent_b.name][0].name == agent_b.name
|
||||
assert all([isinstance(response, StreamingChatMessageContent) for response in responses[agent_b.name]])
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.10 doesn't bound the original function provided to the wraps argument of the patch object.",
|
||||
)
|
||||
async def test_invoke_with_human_response_function():
|
||||
"""Test the invoke method of the HandoffOrchestration with a human response function."""
|
||||
complete_task_agent_instance = MockAgentWithCompleteTaskFunctionCall()
|
||||
normal_agent_instance = MockAgent()
|
||||
call_sequence = iter([normal_agent_instance.invoke_stream, complete_task_agent_instance.invoke_stream])
|
||||
|
||||
user_input_count = 0
|
||||
|
||||
def human_response_function() -> ChatMessageContent:
|
||||
# Simulate user input
|
||||
nonlocal user_input_count
|
||||
user_input_count += 1
|
||||
return ChatMessageContent(role=AuthorRole.USER, content="user_input")
|
||||
|
||||
with (
|
||||
patch.object(MockAgent, "invoke_stream") as mock_invoke_stream,
|
||||
):
|
||||
mock_invoke_stream.side_effect = lambda *args, **kwargs: next(call_sequence)(*args, **kwargs)
|
||||
|
||||
agent_a = MockAgent(name="agent_a")
|
||||
agent_b = MockAgent(name="agent_b")
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = HandoffOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
handoffs={agent_a.name: {agent_b.name: "test"}},
|
||||
human_response_function=human_response_function,
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert user_input_count == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.10 doesn't bound the original function provided to the wraps argument of the patch object.",
|
||||
)
|
||||
async def test_invoke_with_handoff_function_call():
|
||||
"""Test the invoke method of the HandoffOrchestration with a handoff function call."""
|
||||
agent_b = MockAgent()
|
||||
agent_a = MockAgentWithHandoffFunctionCall(agent_b)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
HandoffAgentActor, "_handoff_to_agent", wraps=HandoffAgentActor._handoff_to_agent, autospec=True
|
||||
) as mock_handoff_to_agent,
|
||||
):
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = HandoffOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
handoffs={agent_a.name: {agent_b.name: "test"}},
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert mock_handoff_to_agent.call_count == 1
|
||||
assert mock_handoff_to_agent.call_args_list[0][0][1] == agent_b.name
|
||||
# The kernel in the agent should not be modified
|
||||
assert len(agent_a.kernel.plugins) == 0
|
||||
assert len(agent_b.kernel.plugins) == 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.10 doesn't bound the original function provided to the wraps argument of the patch object.",
|
||||
)
|
||||
async def test_invoke_cancel_before_completion():
|
||||
"""Test the invoke method of the HandoffOrchestration with cancellation before completion."""
|
||||
with (
|
||||
patch.object(MockAgent, "invoke_stream", wraps=MockAgent.invoke_stream, autospec=True) as mock_invoke_stream,
|
||||
):
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = HandoffOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
handoffs={agent_a.name: {agent_b.name: "test"}},
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
# Cancel before first agent completes
|
||||
await asyncio.sleep(0.05)
|
||||
orchestration_result.cancel()
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert mock_invoke_stream.call_count == 1
|
||||
|
||||
|
||||
async def test_invoke_cancel_after_completion():
|
||||
"""Test the invoke method of the HandoffOrchestration with cancellation after completion."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = HandoffOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
handoffs={agent_a.name: {agent_b.name: "test"}},
|
||||
)
|
||||
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
# Wait for the orchestration to complete
|
||||
await orchestration_result.get(1.0)
|
||||
|
||||
with pytest.raises(RuntimeError, match="The invocation has already been completed."):
|
||||
orchestration_result.cancel()
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
async def test_invoke_with_agent_raising_exception():
|
||||
"""Test the invoke method of the HandoffOrchestration with an agent raising an exception."""
|
||||
agent_a = MockAgentWithException()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = HandoffOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
handoffs={agent_a.name: {agent_b.name: "test"}},
|
||||
)
|
||||
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Mock agent exception"):
|
||||
await orchestration_result.get(1.0)
|
||||
assert orchestration_result.exception is not None
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,806 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import sys
|
||||
from typing import Any, Literal
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from semantic_kernel.agents.orchestration.magentic import (
|
||||
MagenticContext,
|
||||
MagenticOrchestration,
|
||||
ProgressLedger,
|
||||
ProgressLedgerItem,
|
||||
StandardMagenticManager,
|
||||
)
|
||||
from semantic_kernel.agents.orchestration.orchestration_base import DefaultTypeAlias, OrchestrationResult
|
||||
from semantic_kernel.agents.orchestration.prompts._magentic_prompts import (
|
||||
ORCHESTRATOR_FINAL_ANSWER_PROMPT,
|
||||
ORCHESTRATOR_PROGRESS_LEDGER_PROMPT,
|
||||
ORCHESTRATOR_TASK_LEDGER_FACTS_PROMPT,
|
||||
ORCHESTRATOR_TASK_LEDGER_FACTS_UPDATE_PROMPT,
|
||||
ORCHESTRATOR_TASK_LEDGER_FULL_PROMPT,
|
||||
ORCHESTRATOR_TASK_LEDGER_PLAN_PROMPT,
|
||||
ORCHESTRATOR_TASK_LEDGER_PLAN_UPDATE_PROMPT,
|
||||
)
|
||||
from semantic_kernel.agents.runtime.in_process.in_process_runtime import InProcessRuntime
|
||||
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from tests.unit.agents.orchestration.conftest import MockAgent, MockAgentWithException, MockRuntime
|
||||
|
||||
|
||||
class MockChatCompletionService(ChatCompletionClientBase):
|
||||
"""A mock chat completion service for testing purposes."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MockPromptExecutionSettings(PromptExecutionSettings):
|
||||
"""A mock prompt execution settings class for testing purposes."""
|
||||
|
||||
response_format: (
|
||||
dict[Literal["type"], Literal["text", "json_object"]] | dict[str, Any] | type[BaseModel] | type | None
|
||||
) = None
|
||||
|
||||
|
||||
# region MagenticOrchestration
|
||||
|
||||
|
||||
async def test_init_member_without_description_throws():
|
||||
"""Test the prepare method of the MagenticOrchestration with a member without description."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
MagenticOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
manager=StandardMagenticManager(
|
||||
chat_completion_service=MockChatCompletionService(ai_model_id="test"),
|
||||
prompt_execution_settings=MockPromptExecutionSettings(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_prepare():
|
||||
"""Test the prepare method of the MagenticOrchestration."""
|
||||
agent_a = MockAgent(description="test agent")
|
||||
agent_b = MockAgent(description="test agent")
|
||||
|
||||
runtime = MockRuntime()
|
||||
|
||||
package_path = "semantic_kernel.agents.orchestration.magentic"
|
||||
with (
|
||||
patch(f"{package_path}.MagenticOrchestration._start"),
|
||||
patch(f"{package_path}.MagenticAgentActor.register") as mock_agent_actor_register,
|
||||
patch(f"{package_path}.MagenticManagerActor.register") as mock_manager_actor_register,
|
||||
patch.object(runtime, "add_subscription") as mock_add_subscription,
|
||||
):
|
||||
orchestration = MagenticOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
manager=StandardMagenticManager(
|
||||
chat_completion_service=MockChatCompletionService(ai_model_id="test"),
|
||||
prompt_execution_settings=MockPromptExecutionSettings(),
|
||||
),
|
||||
)
|
||||
await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
assert mock_agent_actor_register.call_count == 2
|
||||
assert mock_manager_actor_register.call_count == 1
|
||||
assert mock_add_subscription.call_count == 3
|
||||
|
||||
|
||||
ManagerProgressList = [
|
||||
ProgressLedger(
|
||||
is_request_satisfied=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
is_in_loop=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
is_progress_being_made=ProgressLedgerItem(answer=True, reason="mock_reasoning"),
|
||||
next_speaker=ProgressLedgerItem(answer="agent_a", reason="mock_reasoning"),
|
||||
instruction_or_question=ProgressLedgerItem(answer="mock_instruction", reason="mock_reasoning"),
|
||||
),
|
||||
ProgressLedger(
|
||||
is_request_satisfied=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
is_in_loop=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
is_progress_being_made=ProgressLedgerItem(answer=True, reason="mock_reasoning"),
|
||||
next_speaker=ProgressLedgerItem(answer="agent_b", reason="mock_reasoning"),
|
||||
instruction_or_question=ProgressLedgerItem(answer="mock_instruction", reason="mock_reasoning"),
|
||||
),
|
||||
ProgressLedger(
|
||||
is_request_satisfied=ProgressLedgerItem(answer=True, reason="mock_reasoning"),
|
||||
is_in_loop=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
is_progress_being_made=ProgressLedgerItem(answer=True, reason="mock_reasoning"),
|
||||
next_speaker=ProgressLedgerItem(answer="N/A", reason="mock_reasoning"),
|
||||
instruction_or_question=ProgressLedgerItem(answer="mock_instruction", reason="mock_reasoning"),
|
||||
),
|
||||
]
|
||||
|
||||
ManagerProgressListStalling = [
|
||||
ProgressLedger(
|
||||
is_request_satisfied=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
is_in_loop=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
is_progress_being_made=ProgressLedgerItem(answer=True, reason="mock_reasoning"),
|
||||
next_speaker=ProgressLedgerItem(answer="agent_a", reason="mock_reasoning"),
|
||||
instruction_or_question=ProgressLedgerItem(answer="mock_instruction", reason="mock_reasoning"),
|
||||
),
|
||||
ProgressLedger(
|
||||
is_request_satisfied=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
is_in_loop=ProgressLedgerItem(answer=True, reason="mock_reasoning"), # is_in_loop=True
|
||||
is_progress_being_made=ProgressLedgerItem(answer=True, reason="mock_reasoning"),
|
||||
next_speaker=ProgressLedgerItem(answer="agent_a", reason="mock_reasoning"),
|
||||
instruction_or_question=ProgressLedgerItem(answer="mock_instruction", reason="mock_reasoning"),
|
||||
),
|
||||
ProgressLedger(
|
||||
is_request_satisfied=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
is_in_loop=ProgressLedgerItem(answer=True, reason="mock_reasoning"), # is_in_loop=True
|
||||
is_progress_being_made=ProgressLedgerItem(answer=True, reason="mock_reasoning"),
|
||||
next_speaker=ProgressLedgerItem(answer="N/A", reason="mock_reasoning"),
|
||||
instruction_or_question=ProgressLedgerItem(answer="mock_instruction", reason="mock_reasoning"),
|
||||
),
|
||||
ProgressLedger(
|
||||
is_request_satisfied=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
is_in_loop=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
is_progress_being_made=ProgressLedgerItem(answer=True, reason="mock_reasoning"),
|
||||
next_speaker=ProgressLedgerItem(answer="agent_b", reason="mock_reasoning"),
|
||||
instruction_or_question=ProgressLedgerItem(answer="mock_instruction", reason="mock_reasoning"),
|
||||
),
|
||||
ProgressLedger(
|
||||
is_request_satisfied=ProgressLedgerItem(answer=True, reason="mock_reasoning"),
|
||||
is_in_loop=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
is_progress_being_made=ProgressLedgerItem(answer=True, reason="mock_reasoning"),
|
||||
next_speaker=ProgressLedgerItem(answer="N/A", reason="mock_reasoning"),
|
||||
instruction_or_question=ProgressLedgerItem(answer="mock_instruction", reason="mock_reasoning"),
|
||||
),
|
||||
]
|
||||
|
||||
ManagerProgressListUnknownSpeaker = [
|
||||
ProgressLedger(
|
||||
is_request_satisfied=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
is_in_loop=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
is_progress_being_made=ProgressLedgerItem(answer=True, reason="mock_reasoning"),
|
||||
next_speaker=ProgressLedgerItem(answer="unknown", reason="mock_reasoning"),
|
||||
instruction_or_question=ProgressLedgerItem(answer="mock_instruction", reason="mock_reasoning"),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.10 doesn't bound the original function provided to the wraps argument of the patch object.",
|
||||
)
|
||||
async def test_invoke():
|
||||
"""Test the invoke method of the MagenticOrchestration."""
|
||||
with (
|
||||
patch.object(MockAgent, "invoke_stream", wraps=MockAgent.invoke_stream, autospec=True) as mock_invoke_stream,
|
||||
patch.object(
|
||||
MockChatCompletionService, "get_chat_message_content", new_callable=AsyncMock
|
||||
) as mock_get_chat_message_content,
|
||||
patch.object(
|
||||
StandardMagenticManager, "create_progress_ledger", new_callable=AsyncMock, side_effect=ManagerProgressList
|
||||
),
|
||||
):
|
||||
mock_get_chat_message_content.return_value = ChatMessageContent(role="assistant", content="mock_response")
|
||||
chat_completion_service = MockChatCompletionService(ai_model_id="test")
|
||||
prompt_execution_settings = MockPromptExecutionSettings()
|
||||
|
||||
manager = StandardMagenticManager(
|
||||
chat_completion_service=chat_completion_service,
|
||||
prompt_execution_settings=prompt_execution_settings,
|
||||
)
|
||||
|
||||
agent_a = MockAgent(name="agent_a", description="test agent")
|
||||
agent_b = MockAgent(name="agent_b", description="test agent")
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = MagenticOrchestration(members=[agent_a, agent_b], manager=manager)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
result = await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert isinstance(orchestration_result, OrchestrationResult)
|
||||
assert isinstance(result, ChatMessageContent)
|
||||
assert result.role == AuthorRole.ASSISTANT
|
||||
assert result.content == "mock_response"
|
||||
|
||||
assert mock_invoke_stream.call_count == 2
|
||||
assert mock_get_chat_message_content.call_count == 3
|
||||
|
||||
|
||||
async def test_invoke_with_list_error():
|
||||
"""Test the invoke method of the MagenticOrchestration with a list of messages which raises an error."""
|
||||
chat_completion_service = MockChatCompletionService(ai_model_id="test")
|
||||
prompt_execution_settings = MockPromptExecutionSettings()
|
||||
|
||||
manager = StandardMagenticManager(
|
||||
chat_completion_service=chat_completion_service,
|
||||
prompt_execution_settings=prompt_execution_settings,
|
||||
)
|
||||
|
||||
agent_a = MockAgent(name="agent_a", description="test agent")
|
||||
agent_b = MockAgent(name="agent_b", description="test agent")
|
||||
|
||||
messages = [
|
||||
ChatMessageContent(role=AuthorRole.USER, content="test_message_1"),
|
||||
ChatMessageContent(role=AuthorRole.USER, content="test_message_2"),
|
||||
]
|
||||
|
||||
runtime = MockRuntime()
|
||||
|
||||
package_path = "semantic_kernel.agents.orchestration.magentic"
|
||||
with (
|
||||
patch(f"{package_path}.MagenticAgentActor.register"),
|
||||
patch(f"{package_path}.MagenticManagerActor.register"),
|
||||
patch.object(runtime, "add_subscription"),
|
||||
pytest.raises(ValueError),
|
||||
):
|
||||
orchestration = MagenticOrchestration(members=[agent_a, agent_b], manager=manager)
|
||||
orchestration_result = await orchestration.invoke(task=messages, runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
|
||||
|
||||
async def test_invoke_with_agent_raising_exception():
|
||||
"""Test the invoke method of the MagenticOrchestration with a list of messages which raises an error."""
|
||||
with (
|
||||
patch.object(
|
||||
MockChatCompletionService, "get_chat_message_content", new_callable=AsyncMock
|
||||
) as mock_get_chat_message_content,
|
||||
patch.object(
|
||||
StandardMagenticManager, "create_progress_ledger", new_callable=AsyncMock, side_effect=ManagerProgressList
|
||||
),
|
||||
):
|
||||
mock_get_chat_message_content.return_value = ChatMessageContent(role="assistant", content="mock_response")
|
||||
chat_completion_service = MockChatCompletionService(ai_model_id="test")
|
||||
prompt_execution_settings = MockPromptExecutionSettings()
|
||||
|
||||
manager = StandardMagenticManager(
|
||||
chat_completion_service=chat_completion_service,
|
||||
prompt_execution_settings=prompt_execution_settings,
|
||||
)
|
||||
|
||||
agent_a = MockAgentWithException(name="agent_a", description="test agent")
|
||||
agent_b = MockAgent(name="agent_b", description="test agent")
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
orchestration = MagenticOrchestration(members=[agent_a, agent_b], manager=manager)
|
||||
try:
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
with pytest.raises(RuntimeError, match="Mock agent exception"):
|
||||
await orchestration_result.get(1.0)
|
||||
assert orchestration_result.exception is not None
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.10 doesn't bound the original function provided to the wraps argument of the patch object.",
|
||||
)
|
||||
async def test_invoke_with_response_callback():
|
||||
"""Test the invoke method of the MagenticOrchestration with a response callback."""
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
responses: list[DefaultTypeAlias] = []
|
||||
with (
|
||||
patch.object(
|
||||
MockChatCompletionService, "get_chat_message_content", new_callable=AsyncMock
|
||||
) as mock_get_chat_message_content,
|
||||
patch.object(
|
||||
StandardMagenticManager, "create_progress_ledger", new_callable=AsyncMock, side_effect=ManagerProgressList
|
||||
),
|
||||
):
|
||||
mock_get_chat_message_content.return_value = ChatMessageContent(role="assistant", content="mock_response")
|
||||
|
||||
agent_a = MockAgent(name="agent_a", description="test agent")
|
||||
agent_b = MockAgent(name="agent_b", description="test agent")
|
||||
|
||||
try:
|
||||
orchestration = MagenticOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
manager=StandardMagenticManager(
|
||||
chat_completion_service=MockChatCompletionService(ai_model_id="test"),
|
||||
prompt_execution_settings=MockPromptExecutionSettings(),
|
||||
),
|
||||
agent_response_callback=lambda x: responses.append(x),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert len(responses) == 2
|
||||
assert all(isinstance(item, ChatMessageContent) for item in responses)
|
||||
assert all(item.content == "mock_response" for item in responses)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.10 doesn't bound the original function provided to the wraps argument of the patch object.",
|
||||
)
|
||||
async def test_invoke_with_streaming_response_callback():
|
||||
"""Test the invoke method of the MagenticOrchestration with a streaming response callback."""
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
responses: dict[str, list[StreamingChatMessageContent]] = {}
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
MockChatCompletionService, "get_chat_message_content", new_callable=AsyncMock
|
||||
) as mock_get_chat_message_content,
|
||||
patch.object(
|
||||
StandardMagenticManager, "create_progress_ledger", new_callable=AsyncMock, side_effect=ManagerProgressList
|
||||
),
|
||||
):
|
||||
mock_get_chat_message_content.return_value = ChatMessageContent(role="assistant", content="mock_response")
|
||||
|
||||
agent_a = MockAgent(name="agent_a", description="test agent")
|
||||
agent_b = MockAgent(name="agent_b", description="test agent")
|
||||
|
||||
try:
|
||||
orchestration = MagenticOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
manager=StandardMagenticManager(
|
||||
chat_completion_service=MockChatCompletionService(ai_model_id="test"),
|
||||
prompt_execution_settings=MockPromptExecutionSettings(),
|
||||
),
|
||||
streaming_agent_response_callback=lambda x, _: responses.setdefault(x.name, []).append(x),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert len(responses[agent_a.name]) == 2
|
||||
assert len(responses[agent_b.name]) == 2
|
||||
|
||||
assert all(isinstance(item, StreamingChatMessageContent) for item in responses[agent_a.name])
|
||||
assert all(isinstance(item, StreamingChatMessageContent) for item in responses[agent_b.name])
|
||||
|
||||
agent_a_response = sum(responses[agent_a.name][1:], responses[agent_a.name][0])
|
||||
assert agent_a_response.content == "mock_response"
|
||||
agent_b_response = sum(responses[agent_b.name][1:], responses[agent_b.name][0])
|
||||
assert agent_b_response.content == "mock_response"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.10 doesn't bound the original function provided to the wraps argument of the patch object.",
|
||||
)
|
||||
async def test_invoke_with_max_stall_count_exceeded():
|
||||
""" "Test the invoke method of the MagenticOrchestration with max stall count exceeded."""
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
with (
|
||||
patch.object(MockAgent, "invoke_stream", wraps=MockAgent.invoke_stream, autospec=True) as mock_invoke_stream,
|
||||
patch.object(
|
||||
MockChatCompletionService, "get_chat_message_content", new_callable=AsyncMock
|
||||
) as mock_get_chat_message_content,
|
||||
patch.object(
|
||||
StandardMagenticManager,
|
||||
"create_progress_ledger",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=ManagerProgressListStalling,
|
||||
),
|
||||
):
|
||||
mock_get_chat_message_content.return_value = ChatMessageContent(role="assistant", content="mock_response")
|
||||
|
||||
agent_a = MockAgent(name="agent_a", description="test agent")
|
||||
agent_b = MockAgent(name="agent_b", description="test agent")
|
||||
|
||||
try:
|
||||
orchestration = MagenticOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
manager=StandardMagenticManager(
|
||||
chat_completion_service=MockChatCompletionService(ai_model_id="test"),
|
||||
prompt_execution_settings=MockPromptExecutionSettings(),
|
||||
max_stall_count=1,
|
||||
),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert mock_invoke_stream.call_count == 3
|
||||
# Exceeding max stall count will trigger replanning, which will recreate the facts and plan,
|
||||
# resulting in two additional calls to get_chat_message_content compared to the `test_invoke` test.
|
||||
assert mock_get_chat_message_content.call_count == 5
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.10 doesn't bound the original function provided to the wraps argument of the patch object.",
|
||||
)
|
||||
async def test_invoke_with_max_round_count_exceeded():
|
||||
""" "Test the invoke method of the MagenticOrchestration with max round count exceeded."""
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
with (
|
||||
patch.object(MockAgent, "invoke_stream", wraps=MockAgent.invoke_stream, autospec=True) as mock_invoke_stream,
|
||||
patch.object(
|
||||
MockChatCompletionService, "get_chat_message_content", new_callable=AsyncMock
|
||||
) as mock_get_chat_message_content,
|
||||
patch.object(
|
||||
StandardMagenticManager,
|
||||
"create_progress_ledger",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=ManagerProgressListStalling,
|
||||
),
|
||||
):
|
||||
mock_get_chat_message_content.return_value = ChatMessageContent(role="assistant", content="mock_response")
|
||||
|
||||
agent_a = MockAgent(name="agent_a", description="test agent")
|
||||
agent_b = MockAgent(name="agent_b", description="test agent")
|
||||
|
||||
try:
|
||||
orchestration = MagenticOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
manager=StandardMagenticManager(
|
||||
chat_completion_service=MockChatCompletionService(ai_model_id="test"),
|
||||
prompt_execution_settings=MockPromptExecutionSettings(),
|
||||
max_round_count=1,
|
||||
),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
result = await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
# Partial result will be returned when max round count is exceeded.
|
||||
assert result.content == mock_get_chat_message_content.return_value.content
|
||||
assert mock_invoke_stream.call_count == 1
|
||||
# Planning will be called once, so the facts and plan will be created once.
|
||||
assert mock_get_chat_message_content.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.10 doesn't bound the original function provided to the wraps argument of the patch object.",
|
||||
)
|
||||
async def test_invoke_with_max_reset_count_exceeded():
|
||||
""" "Test the invoke method of the MagenticOrchestration with max reset count exceeded."""
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
with (
|
||||
patch.object(MockAgent, "invoke_stream", wraps=MockAgent.invoke_stream, autospec=True) as mock_invoke_stream,
|
||||
patch.object(
|
||||
MockChatCompletionService, "get_chat_message_content", new_callable=AsyncMock
|
||||
) as mock_get_chat_message_content,
|
||||
patch.object(
|
||||
StandardMagenticManager,
|
||||
"create_progress_ledger",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=ManagerProgressListStalling,
|
||||
),
|
||||
):
|
||||
mock_get_chat_message_content.return_value = ChatMessageContent(role="assistant", content="mock_response")
|
||||
|
||||
agent_a = MockAgent(name="agent_a", description="test agent")
|
||||
agent_b = MockAgent(name="agent_b", description="test agent")
|
||||
|
||||
try:
|
||||
orchestration = MagenticOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
manager=StandardMagenticManager(
|
||||
chat_completion_service=MockChatCompletionService(ai_model_id="test"),
|
||||
prompt_execution_settings=MockPromptExecutionSettings(),
|
||||
max_stall_count=0, # No stall allowed
|
||||
max_reset_count=0, # No reset allowed
|
||||
),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
result = await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
# Partial result will be returned when max reset count is exceeded. The test emits content based on the prompt
|
||||
# so check that the content is not None and not an exact match to a mock response.
|
||||
assert result.content is not None
|
||||
assert mock_invoke_stream.call_count == 1
|
||||
# Planning and replanning will be each called once, so the facts and plan will be created twice.
|
||||
assert mock_get_chat_message_content.call_count == 4
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.10 doesn't bound the original function provided to the wraps argument of the patch object.",
|
||||
)
|
||||
async def test_invoke_with_unknown_speaker():
|
||||
"""Test the invoke method of the MagenticOrchestration with an unknown speaker."""
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
MockChatCompletionService, "get_chat_message_content", new_callable=AsyncMock
|
||||
) as mock_get_chat_message_content,
|
||||
patch.object(
|
||||
StandardMagenticManager,
|
||||
"create_progress_ledger",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=ManagerProgressListUnknownSpeaker,
|
||||
),
|
||||
pytest.raises(ValueError),
|
||||
):
|
||||
mock_get_chat_message_content.return_value = ChatMessageContent(role="assistant", content="mock_response")
|
||||
|
||||
agent_a = MockAgent(name="agent_a", description="test agent")
|
||||
agent_b = MockAgent(name="agent_b", description="test agent")
|
||||
|
||||
try:
|
||||
orchestration = MagenticOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
manager=StandardMagenticManager(
|
||||
chat_completion_service=MockChatCompletionService(ai_model_id="test"),
|
||||
prompt_execution_settings=MockPromptExecutionSettings(),
|
||||
),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
# endregion MagenticOrchestration
|
||||
|
||||
# region StandardMagenticManager
|
||||
|
||||
|
||||
def test_standard_magentic_manager_init():
|
||||
"""Test the initialization of the StandardMagenticManager."""
|
||||
chat_completion_service = MockChatCompletionService(ai_model_id="test")
|
||||
prompt_execution_settings = MockPromptExecutionSettings()
|
||||
|
||||
manager = StandardMagenticManager(
|
||||
chat_completion_service=chat_completion_service,
|
||||
prompt_execution_settings=prompt_execution_settings,
|
||||
)
|
||||
|
||||
assert manager.max_stall_count > 0
|
||||
assert manager.max_reset_count is None
|
||||
assert manager.max_round_count is None
|
||||
assert (
|
||||
manager.task_ledger_facts_prompt is not None
|
||||
and manager.task_ledger_facts_prompt == ORCHESTRATOR_TASK_LEDGER_FACTS_PROMPT
|
||||
)
|
||||
assert (
|
||||
manager.task_ledger_plan_prompt is not None
|
||||
and manager.task_ledger_plan_prompt == ORCHESTRATOR_TASK_LEDGER_PLAN_PROMPT
|
||||
)
|
||||
assert (
|
||||
manager.task_ledger_full_prompt is not None
|
||||
and manager.task_ledger_full_prompt == ORCHESTRATOR_TASK_LEDGER_FULL_PROMPT
|
||||
)
|
||||
assert (
|
||||
manager.task_ledger_facts_update_prompt is not None
|
||||
and manager.task_ledger_facts_update_prompt == ORCHESTRATOR_TASK_LEDGER_FACTS_UPDATE_PROMPT
|
||||
)
|
||||
assert (
|
||||
manager.task_ledger_plan_update_prompt is not None
|
||||
and manager.task_ledger_plan_update_prompt == ORCHESTRATOR_TASK_LEDGER_PLAN_UPDATE_PROMPT
|
||||
)
|
||||
assert (
|
||||
manager.progress_ledger_prompt is not None
|
||||
and manager.progress_ledger_prompt == ORCHESTRATOR_PROGRESS_LEDGER_PROMPT
|
||||
)
|
||||
assert manager.final_answer_prompt is not None and manager.final_answer_prompt == ORCHESTRATOR_FINAL_ANSWER_PROMPT
|
||||
|
||||
|
||||
def test_standard_magentic_manager_init_with_custom_prompts():
|
||||
"""Test the initialization of the StandardMagenticManager with custom prompts."""
|
||||
chat_completion_service = MockChatCompletionService(ai_model_id="test")
|
||||
prompt_execution_settings = MockPromptExecutionSettings()
|
||||
|
||||
manager = StandardMagenticManager(
|
||||
chat_completion_service=chat_completion_service,
|
||||
prompt_execution_settings=prompt_execution_settings,
|
||||
task_ledger_facts_prompt="custom_task_ledger_facts_prompt",
|
||||
task_ledger_plan_prompt="custom_task_ledger_plan_prompt",
|
||||
task_ledger_full_prompt="custom_task_ledger_full_prompt",
|
||||
task_ledger_facts_update_prompt="custom_task_ledger_facts_update_prompt",
|
||||
task_ledger_plan_update_prompt="custom_task_ledger_plan_update_prompt",
|
||||
progress_ledger_prompt="custom_progress_ledger_prompt",
|
||||
final_answer_prompt="custom_final_answer_prompt",
|
||||
)
|
||||
|
||||
assert manager.task_ledger_facts_prompt == "custom_task_ledger_facts_prompt"
|
||||
assert manager.task_ledger_plan_prompt == "custom_task_ledger_plan_prompt"
|
||||
assert manager.task_ledger_full_prompt == "custom_task_ledger_full_prompt"
|
||||
assert manager.task_ledger_facts_update_prompt == "custom_task_ledger_facts_update_prompt"
|
||||
assert manager.task_ledger_plan_update_prompt == "custom_task_ledger_plan_update_prompt"
|
||||
assert manager.progress_ledger_prompt == "custom_progress_ledger_prompt"
|
||||
assert manager.final_answer_prompt == "custom_final_answer_prompt"
|
||||
|
||||
|
||||
def test_standard_magentic_manager_init_with_invalid_prompt_execution_settings():
|
||||
"""Test the initialization of the StandardMagenticManager with invalid prompt execution settings."""
|
||||
chat_completion_service = MockChatCompletionService(ai_model_id="test")
|
||||
prompt_execution_settings = PromptExecutionSettings()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
StandardMagenticManager(
|
||||
chat_completion_service=chat_completion_service,
|
||||
prompt_execution_settings=prompt_execution_settings,
|
||||
)
|
||||
|
||||
|
||||
def test_standard_magentic_manager_init_without_prompt_execution_settings():
|
||||
"""Test the initialization of the StandardMagenticManager without prompt execution settings."""
|
||||
# The default prompt execution settings of the mock chat completion service
|
||||
# does not support structured output.
|
||||
chat_completion_service = MockChatCompletionService(ai_model_id="test")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
StandardMagenticManager(chat_completion_service=chat_completion_service)
|
||||
|
||||
|
||||
async def test_standard_magentic_manager_plan():
|
||||
"""Test the plan method of the StandardMagenticManager."""
|
||||
|
||||
with patch.object(
|
||||
MockChatCompletionService, "get_chat_message_content", new_callable=AsyncMock
|
||||
) as mock_get_chat_message_content:
|
||||
mock_get_chat_message_content.return_value = ChatMessageContent(role="assistant", content="mock_response")
|
||||
chat_completion_service = MockChatCompletionService(ai_model_id="test")
|
||||
prompt_execution_settings = MockPromptExecutionSettings()
|
||||
|
||||
manager = StandardMagenticManager(
|
||||
chat_completion_service=chat_completion_service,
|
||||
prompt_execution_settings=prompt_execution_settings,
|
||||
task_ledger_facts_prompt="custom_task_ledger_facts_prompt",
|
||||
task_ledger_plan_prompt="custom_task_ledger_plan_prompt {{$team}}",
|
||||
)
|
||||
|
||||
magentic_context = MagenticContext(
|
||||
chat_history=ChatHistory(),
|
||||
task=ChatMessageContent(role="user", content="test_message"),
|
||||
participant_descriptions={"agent_a": "test_agent_a", "agent_b": "test_agent_b"},
|
||||
)
|
||||
|
||||
task_ledger = await manager.plan(magentic_context.model_copy(deep=True))
|
||||
|
||||
assert isinstance(task_ledger, ChatMessageContent)
|
||||
assert task_ledger.content.count("mock_response") == 2
|
||||
assert "test_message" in task_ledger.content
|
||||
assert "{'agent_a': 'test_agent_a', 'agent_b': 'test_agent_b'}" in task_ledger.content
|
||||
|
||||
assert mock_get_chat_message_content.call_count == 2
|
||||
assert (
|
||||
mock_get_chat_message_content.call_args_list[0][0][0].messages[0].content
|
||||
== "custom_task_ledger_facts_prompt"
|
||||
)
|
||||
assert (
|
||||
mock_get_chat_message_content.call_args_list[1][0][0].messages[2].content
|
||||
== "custom_task_ledger_plan_prompt {'agent_a': 'test_agent_a', 'agent_b': 'test_agent_b'}"
|
||||
)
|
||||
|
||||
|
||||
async def test_standard_magentic_manager_replan():
|
||||
"""Test the replan method of the StandardMagenticManager."""
|
||||
|
||||
with patch.object(
|
||||
MockChatCompletionService, "get_chat_message_content", new_callable=AsyncMock
|
||||
) as mock_get_chat_message_content:
|
||||
mock_get_chat_message_content.return_value = ChatMessageContent(role="assistant", content="mock_response")
|
||||
|
||||
chat_completion_service = MockChatCompletionService(ai_model_id="test")
|
||||
prompt_execution_settings = MockPromptExecutionSettings()
|
||||
|
||||
manager = StandardMagenticManager(
|
||||
chat_completion_service=chat_completion_service,
|
||||
prompt_execution_settings=prompt_execution_settings,
|
||||
task_ledger_facts_update_prompt="custom_task_ledger_facts_prompt {{$old_facts}}",
|
||||
task_ledger_plan_update_prompt="custom_task_ledger_plan_prompt {{$team}}",
|
||||
)
|
||||
|
||||
magentic_context = MagenticContext(
|
||||
chat_history=ChatHistory(),
|
||||
task=ChatMessageContent(role="user", content="test_message"),
|
||||
participant_descriptions={"agent_a": "test_agent_a", "agent_b": "test_agent_b"},
|
||||
)
|
||||
|
||||
# Need to plan before replanning
|
||||
_ = await manager.plan(magentic_context.model_copy(deep=True))
|
||||
task_ledger = await manager.replan(magentic_context.model_copy(deep=True))
|
||||
|
||||
assert isinstance(task_ledger, ChatMessageContent)
|
||||
assert task_ledger.content.count("mock_response") == 2
|
||||
assert "test_message" in task_ledger.content
|
||||
assert "{'agent_a': 'test_agent_a', 'agent_b': 'test_agent_b'}" in task_ledger.content
|
||||
|
||||
assert mock_get_chat_message_content.call_count == 4
|
||||
assert (
|
||||
mock_get_chat_message_content.call_args_list[2][0][0].messages[0].content
|
||||
== "custom_task_ledger_facts_prompt mock_response"
|
||||
)
|
||||
assert (
|
||||
mock_get_chat_message_content.call_args_list[3][0][0].messages[2].content
|
||||
== "custom_task_ledger_plan_prompt {'agent_a': 'test_agent_a', 'agent_b': 'test_agent_b'}"
|
||||
)
|
||||
|
||||
|
||||
async def test_standard_magentic_manager_replan_without_plan():
|
||||
"""Test the replan method of the StandardMagenticManager."""
|
||||
|
||||
chat_completion_service = MockChatCompletionService(ai_model_id="test")
|
||||
prompt_execution_settings = MockPromptExecutionSettings()
|
||||
|
||||
manager = StandardMagenticManager(
|
||||
chat_completion_service=chat_completion_service,
|
||||
prompt_execution_settings=prompt_execution_settings,
|
||||
)
|
||||
|
||||
magentic_context = MagenticContext(
|
||||
chat_history=ChatHistory(),
|
||||
task=ChatMessageContent(role="user", content="test_message"),
|
||||
participant_descriptions={"agent_a": "test_agent_a", "agent_b": "test_agent_b"},
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
_ = await manager.replan(magentic_context.model_copy(deep=True))
|
||||
|
||||
|
||||
async def test_standard_magentic_manager_create_progress_ledger():
|
||||
"""Test the create_progress_ledger method of the StandardMagenticManager."""
|
||||
|
||||
mock_progress_ledger = ProgressLedger(
|
||||
is_request_satisfied=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
is_in_loop=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
is_progress_being_made=ProgressLedgerItem(answer=False, reason="mock_reasoning"),
|
||||
next_speaker=ProgressLedgerItem(answer="agent_a", reason="mock_reasoning"),
|
||||
instruction_or_question=ProgressLedgerItem(answer="mock_instruction", reason="mock_reasoning"),
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
MockChatCompletionService, "get_chat_message_content", new_callable=AsyncMock
|
||||
) as mock_get_chat_message_content:
|
||||
mock_get_chat_message_content.return_value = ChatMessageContent(
|
||||
role="assistant", content=mock_progress_ledger.model_dump_json()
|
||||
)
|
||||
|
||||
chat_completion_service = MockChatCompletionService(ai_model_id="test")
|
||||
prompt_execution_settings = MockPromptExecutionSettings()
|
||||
|
||||
manager = StandardMagenticManager(
|
||||
chat_completion_service=chat_completion_service,
|
||||
prompt_execution_settings=prompt_execution_settings,
|
||||
)
|
||||
|
||||
magentic_context = MagenticContext(
|
||||
chat_history=ChatHistory(),
|
||||
task=ChatMessageContent(role="user", content="test_message"),
|
||||
participant_descriptions={"agent_a": "test_agent_a", "agent_b": "test_agent_b"},
|
||||
)
|
||||
|
||||
progress_ledger = await manager.create_progress_ledger(magentic_context.model_copy(deep=True))
|
||||
|
||||
assert isinstance(progress_ledger, ProgressLedger)
|
||||
assert progress_ledger == mock_progress_ledger
|
||||
|
||||
assert (
|
||||
"{'agent_a': 'test_agent_a', 'agent_b': 'test_agent_b'}"
|
||||
in mock_get_chat_message_content.call_args_list[0][0][0].messages[0].content
|
||||
)
|
||||
assert "agent_a, agent_b" in mock_get_chat_message_content.call_args_list[0][0][0].messages[0].content
|
||||
assert (
|
||||
magentic_context.task.content in mock_get_chat_message_content.call_args_list[0][0][0].messages[0].content
|
||||
)
|
||||
assert mock_get_chat_message_content.call_args_list[0][0][1].extension_data["response_format"] == ProgressLedger
|
||||
|
||||
|
||||
# endregion MagenticManager
|
||||
@@ -0,0 +1,422 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.agent import Agent, AgentResponseItem, AgentThread
|
||||
from semantic_kernel.agents.orchestration.orchestration_base import DefaultTypeAlias, OrchestrationBase, TIn, TOut
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
from tests.unit.agents.orchestration.conftest import MockRuntime
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # pragma: no cover
|
||||
|
||||
|
||||
class MockAgent(Agent):
|
||||
"""A mock agent for testing purposes."""
|
||||
|
||||
@override
|
||||
async def get_response(
|
||||
self,
|
||||
*,
|
||||
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs,
|
||||
) -> AgentResponseItem[ChatMessageContent]:
|
||||
pass
|
||||
|
||||
@override
|
||||
async def invoke(
|
||||
self,
|
||||
*,
|
||||
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
|
||||
thread: AgentThread | None = None,
|
||||
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
|
||||
**kwargs,
|
||||
) -> AgentResponseItem[ChatMessageContent]:
|
||||
pass
|
||||
|
||||
@override
|
||||
async def invoke_stream(
|
||||
self,
|
||||
*,
|
||||
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
|
||||
thread: AgentThread | None = None,
|
||||
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
|
||||
**kwargs,
|
||||
) -> AsyncIterable[AgentResponseItem[StreamingChatMessageContent]]:
|
||||
pass
|
||||
|
||||
|
||||
class MockOrchestration(OrchestrationBase[TIn, TOut]):
|
||||
"""A mock orchestration base for testing purposes."""
|
||||
|
||||
async def _start(self, task, runtime, internal_topic_type, collection_agent_type):
|
||||
pass
|
||||
|
||||
async def _prepare(self, runtime, internal_topic_type, exception_callback, result_callback):
|
||||
pass
|
||||
|
||||
|
||||
def test_orchestration_init():
|
||||
"""Test the initialization of the MockOrchestration."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
agent_c = MockAgent()
|
||||
|
||||
orchestration = MockOrchestration(
|
||||
members=[agent_a, agent_b, agent_c],
|
||||
name="test_orchestration",
|
||||
description="Test Orchestration",
|
||||
)
|
||||
|
||||
assert orchestration.name == "test_orchestration"
|
||||
assert orchestration.description == "Test Orchestration"
|
||||
|
||||
assert len(orchestration._members) == 3
|
||||
assert orchestration._input_transform is not None
|
||||
assert orchestration._output_transform is not None
|
||||
assert orchestration._agent_response_callback is None
|
||||
|
||||
|
||||
def test_orchestration_init_with_default_values():
|
||||
"""Test the initialization of the MockOrchestration with default values."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
orchestration = MockOrchestration(members=[agent_a, agent_b])
|
||||
|
||||
assert orchestration.name
|
||||
assert orchestration.description
|
||||
|
||||
assert len(orchestration._members) == 2
|
||||
assert orchestration._input_transform is not None
|
||||
assert orchestration._output_transform is not None
|
||||
assert orchestration._agent_response_callback is None
|
||||
|
||||
|
||||
def test_orchestration_init_with_no_members():
|
||||
"""Test the initialization of the OrchestrationBase with no members."""
|
||||
with pytest.raises(ValueError):
|
||||
_ = MockOrchestration(members=[])
|
||||
|
||||
|
||||
def test_orchestration_set_types():
|
||||
"""Test the set_types method of OrchestrationBase."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
# Test with default types
|
||||
orchestration_a = MockOrchestration(members=[agent_a, agent_b])
|
||||
orchestration_a._set_types()
|
||||
|
||||
assert orchestration_a.t_in is DefaultTypeAlias
|
||||
assert orchestration_a.t_out is DefaultTypeAlias
|
||||
|
||||
# Test with a custom input type and default output type
|
||||
orchestration_c = MockOrchestration[int](members=[agent_a, agent_b])
|
||||
orchestration_c._set_types()
|
||||
|
||||
assert orchestration_c.t_in is int
|
||||
assert orchestration_c.t_out is DefaultTypeAlias
|
||||
|
||||
# Test with a custom input type and custom output type
|
||||
orchestration_b = MockOrchestration[str, int](members=[agent_a, agent_b])
|
||||
orchestration_b._set_types()
|
||||
|
||||
assert orchestration_b.t_in is str
|
||||
assert orchestration_b.t_out is int
|
||||
|
||||
# Test with an incorrect number of types
|
||||
with pytest.raises(TypeError):
|
||||
orchestration_d = MockOrchestration[str, str, str](members=[agent_a, agent_b])
|
||||
orchestration_d._set_types()
|
||||
|
||||
|
||||
async def test_orchestration_invoke_with_str():
|
||||
"""Test the invoke method of OrchestrationBase with a string input."""
|
||||
orchestration = MockOrchestration(members=[MockAgent(), MockAgent()])
|
||||
|
||||
with patch.object(orchestration, "_start") as mock_start:
|
||||
await orchestration.invoke("Test message", MockRuntime())
|
||||
mock_start.assert_called_once_with(
|
||||
ChatMessageContent(role=AuthorRole.USER, content="Test message"), ANY, ANY, ANY
|
||||
)
|
||||
|
||||
|
||||
async def test_orchestration_invoke_with_chat_message_content():
|
||||
"""Test the invoke method of OrchestrationBase with a ChatMessageContent input."""
|
||||
orchestration = MockOrchestration(members=[MockAgent(), MockAgent()])
|
||||
|
||||
chat_message_content = ChatMessageContent(role=AuthorRole.USER, content="Test message")
|
||||
with patch.object(orchestration, "_start") as mock_start:
|
||||
await orchestration.invoke(chat_message_content, MockRuntime())
|
||||
mock_start.assert_called_once_with(chat_message_content, ANY, ANY, ANY)
|
||||
|
||||
chat_message_content_list = [
|
||||
ChatMessageContent(role=AuthorRole.USER, content="Test message 1"),
|
||||
ChatMessageContent(role=AuthorRole.USER, content="Test message 2"),
|
||||
]
|
||||
with patch.object(orchestration, "_start") as mock_start:
|
||||
await orchestration.invoke(chat_message_content_list, MockRuntime())
|
||||
mock_start.assert_called_once_with(chat_message_content_list, ANY, ANY, ANY)
|
||||
|
||||
|
||||
async def test_orchestration_invoke_with_custom_type():
|
||||
"""Test the invoke method of OrchestrationBase with a custom type."""
|
||||
|
||||
@dataclass
|
||||
class CustomType:
|
||||
value: str
|
||||
number: int
|
||||
|
||||
orchestration = MockOrchestration[CustomType, TOut](members=[MockAgent(), MockAgent()])
|
||||
|
||||
custom_type_instance = CustomType(value="Test message", number=42)
|
||||
with patch.object(orchestration, "_start") as mock_start:
|
||||
await orchestration.invoke(custom_type_instance, MockRuntime())
|
||||
mock_start.assert_called_once_with(
|
||||
ChatMessageContent(role=AuthorRole.USER, content=json.dumps(custom_type_instance.__dict__)), ANY, ANY, ANY
|
||||
)
|
||||
|
||||
|
||||
async def test_orchestration_invoke_with_custom_type_async_input_transform():
|
||||
"""Test the invoke method of OrchestrationBase with a custom type and async input transform."""
|
||||
|
||||
@dataclass
|
||||
class CustomType:
|
||||
value: str
|
||||
number: int
|
||||
|
||||
async def async_input_transform(input_data: CustomType) -> ChatMessageContent:
|
||||
await asyncio.sleep(0.1) # Simulate async processing
|
||||
return ChatMessageContent(role=AuthorRole.USER, content="Test message")
|
||||
|
||||
orchestration = MockOrchestration[CustomType, TOut](
|
||||
members=[MockAgent(), MockAgent()],
|
||||
input_transform=async_input_transform,
|
||||
)
|
||||
|
||||
custom_type_instance = CustomType(value="Test message", number=42)
|
||||
with patch.object(orchestration, "_start") as mock_start:
|
||||
await orchestration.invoke(custom_type_instance, MockRuntime())
|
||||
mock_start.assert_called_once_with(
|
||||
ChatMessageContent(role=AuthorRole.USER, content="Test message"), ANY, ANY, ANY
|
||||
)
|
||||
|
||||
|
||||
def test_default_input_transform_default_type_alias():
|
||||
"""Test the default_input_transform method of OrchestrationBase."""
|
||||
orchestration = MockOrchestration(members=[MockAgent(), MockAgent()])
|
||||
orchestration._set_types()
|
||||
|
||||
chat_message_content = ChatMessageContent(role=AuthorRole.USER, content="Test message")
|
||||
transformed_input = orchestration._default_input_transform(chat_message_content)
|
||||
|
||||
assert isinstance(transformed_input, ChatMessageContent)
|
||||
|
||||
chat_message_content_list = [
|
||||
ChatMessageContent(role=AuthorRole.USER, content="Test message 1"),
|
||||
ChatMessageContent(role=AuthorRole.USER, content="Test message 2"),
|
||||
]
|
||||
transformed_input_list = orchestration._default_input_transform(chat_message_content_list)
|
||||
|
||||
assert isinstance(transformed_input_list, list) and all(
|
||||
isinstance(item, ChatMessageContent) for item in transformed_input_list
|
||||
)
|
||||
|
||||
|
||||
def test_default_input_transform_custom_type():
|
||||
"""Test the default_input_transform method of OrchestrationBase with a custom type."""
|
||||
|
||||
@dataclass
|
||||
class CustomType:
|
||||
value: str
|
||||
number: int
|
||||
|
||||
orchestration_a = MockOrchestration[CustomType, TOut](members=[MockAgent(), MockAgent()])
|
||||
orchestration_a._set_types()
|
||||
|
||||
custom_type_instance = CustomType(value="Test message", number=42)
|
||||
transformed_input_a = orchestration_a._default_input_transform(custom_type_instance)
|
||||
|
||||
assert isinstance(transformed_input_a, ChatMessageContent)
|
||||
assert CustomType(**json.loads(transformed_input_a.content)) == custom_type_instance
|
||||
assert transformed_input_a.role == AuthorRole.USER
|
||||
|
||||
class CustomModel(KernelBaseModel):
|
||||
value: str
|
||||
number: int
|
||||
|
||||
orchestration_b = MockOrchestration[CustomModel, TOut](members=[MockAgent(), MockAgent()])
|
||||
orchestration_b._set_types()
|
||||
|
||||
custom_model_instance = CustomModel(value="Test message", number=42)
|
||||
transformed_input_b = orchestration_b._default_input_transform(custom_model_instance)
|
||||
|
||||
assert isinstance(transformed_input_b, ChatMessageContent)
|
||||
assert CustomModel.model_validate_json(transformed_input_b.content) == custom_model_instance
|
||||
assert transformed_input_b.role == AuthorRole.USER
|
||||
|
||||
|
||||
def test_default_input_transform_custom_type_error():
|
||||
"""Test the default_input_transform method of OrchestrationBase with an incorrect type."""
|
||||
|
||||
@dataclass
|
||||
class CustomType:
|
||||
value: str
|
||||
number: int
|
||||
|
||||
class CustomModel(KernelBaseModel):
|
||||
value: str
|
||||
number: int
|
||||
|
||||
orchestration = MockOrchestration[CustomModel, TOut](members=[MockAgent(), MockAgent()])
|
||||
orchestration._set_types()
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
custom_type_instance = CustomType(value="Test message", number=42)
|
||||
orchestration._default_input_transform(custom_type_instance)
|
||||
|
||||
|
||||
def test_default_output_transform_default_type_alias():
|
||||
"""Test the default_output_transform method of OrchestrationBase."""
|
||||
orchestration = MockOrchestration(members=[MockAgent(), MockAgent()])
|
||||
orchestration._set_types()
|
||||
|
||||
chat_message_content = ChatMessageContent(role=AuthorRole.USER, content="Test message")
|
||||
transformed_output = orchestration._default_output_transform(chat_message_content)
|
||||
|
||||
assert isinstance(transformed_output, ChatMessageContent)
|
||||
|
||||
chat_message_content_list = [
|
||||
ChatMessageContent(role=AuthorRole.USER, content="Test message 1"),
|
||||
ChatMessageContent(role=AuthorRole.USER, content="Test message 2"),
|
||||
]
|
||||
transformed_output_list = orchestration._default_output_transform(chat_message_content_list)
|
||||
|
||||
assert isinstance(transformed_output_list, list) and all(
|
||||
isinstance(item, ChatMessageContent) for item in transformed_output_list
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError, match="Invalid output message type"):
|
||||
orchestration._default_output_transform("Invalid type")
|
||||
|
||||
|
||||
def test_default_output_transform_custom_type():
|
||||
"""Test the default_output_transform method of OrchestrationBase with a custom type."""
|
||||
|
||||
@dataclass
|
||||
class CustomType:
|
||||
value: str
|
||||
number: int
|
||||
|
||||
orchestration_a = MockOrchestration[TIn, CustomType](members=[MockAgent(), MockAgent()])
|
||||
orchestration_a._set_types()
|
||||
|
||||
custom_type_instance = CustomType(value="Test message", number=42)
|
||||
chat_message_content = ChatMessageContent(role=AuthorRole.USER, content=json.dumps(custom_type_instance.__dict__))
|
||||
|
||||
transformed_output_a = orchestration_a._default_output_transform(chat_message_content)
|
||||
|
||||
assert isinstance(transformed_output_a, CustomType)
|
||||
assert transformed_output_a == custom_type_instance
|
||||
|
||||
class CustomModel(KernelBaseModel):
|
||||
value: str
|
||||
number: int
|
||||
|
||||
orchestration_b = MockOrchestration[TIn, CustomModel](members=[MockAgent(), MockAgent()])
|
||||
orchestration_b._set_types()
|
||||
|
||||
custom_model_instance = CustomModel(value="Test message", number=42)
|
||||
chat_message_content = ChatMessageContent(role=AuthorRole.USER, content=custom_model_instance.model_dump_json())
|
||||
|
||||
transformed_output_b = orchestration_b._default_output_transform(chat_message_content)
|
||||
|
||||
assert isinstance(transformed_output_b, CustomModel)
|
||||
assert transformed_output_b == custom_model_instance
|
||||
|
||||
|
||||
def test_default_output_transform_custom_type_error():
|
||||
"""Test the default_output_transform method of OrchestrationBase with an incorrect type."""
|
||||
|
||||
@dataclass
|
||||
class CustomType:
|
||||
value: str
|
||||
number: int
|
||||
|
||||
class CustomModel(KernelBaseModel):
|
||||
value: str
|
||||
number: int
|
||||
|
||||
orchestration = MockOrchestration[TIn, CustomModel](members=[MockAgent(), MockAgent()])
|
||||
orchestration._set_types()
|
||||
|
||||
with pytest.raises(TypeError, match="Unable to transform output message"):
|
||||
custom_type_instance = CustomType(value="Test message", number=42)
|
||||
orchestration._default_output_transform(custom_type_instance)
|
||||
|
||||
|
||||
async def test_invoke_with_timeout_error():
|
||||
"""Test the invoke method of the MockOrchestration with a timeout error."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
orchestration = MockOrchestration(members=[agent_a, agent_b])
|
||||
# The orchestration_result will never be set by the MockOrchestration
|
||||
orchestration_result = await orchestration.invoke(
|
||||
task="test_message",
|
||||
runtime=MockRuntime(),
|
||||
)
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await orchestration_result.get(timeout=0.1)
|
||||
|
||||
|
||||
async def test_invoke_cancel_before_completion():
|
||||
"""Test the invoke method of the MockOrchestration with cancellation before completion."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
orchestration = MockOrchestration(members=[agent_a, agent_b])
|
||||
# The orchestration_result will never be set by the MockOrchestration
|
||||
orchestration_result = await orchestration.invoke(
|
||||
task="test_message",
|
||||
runtime=MockRuntime(),
|
||||
)
|
||||
|
||||
# Cancel the orchestration before completion
|
||||
orchestration_result.cancel()
|
||||
|
||||
with pytest.raises(RuntimeError, match="The invocation was canceled before it could complete."):
|
||||
await orchestration_result.get()
|
||||
|
||||
|
||||
async def test_invoke_with_double_cancel():
|
||||
"""Test the invoke method of the MockOrchestration with double cancel."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
orchestration = MockOrchestration(members=[agent_a, agent_b])
|
||||
# The orchestration_result will never be set by the MockOrchestration
|
||||
orchestration_result = await orchestration.invoke(
|
||||
task="test_message",
|
||||
runtime=MockRuntime(),
|
||||
)
|
||||
|
||||
orchestration_result.cancel()
|
||||
# Cancelling again should raise an error
|
||||
with pytest.raises(RuntimeError, match="The invocation has already been canceled."):
|
||||
orchestration_result.cancel()
|
||||
@@ -0,0 +1,173 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Literal
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from semantic_kernel.agents.orchestration.tools import structured_outputs_transform
|
||||
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
|
||||
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # pragma: no cover
|
||||
|
||||
|
||||
class MockPromptExecutionSettings(PromptExecutionSettings):
|
||||
"""A mock prompt execution settings class for testing purposes."""
|
||||
|
||||
response_format: (
|
||||
dict[Literal["type"], Literal["text", "json_object"]] | dict[str, Any] | type[BaseModel] | type | None
|
||||
) = None
|
||||
|
||||
|
||||
class MockChatCompletionService(ChatCompletionClientBase):
|
||||
"""A mock chat completion service for testing purposes."""
|
||||
|
||||
@override
|
||||
def get_prompt_execution_settings_class(self) -> type["PromptExecutionSettings"]:
|
||||
"""Get the request settings class."""
|
||||
return MockPromptExecutionSettings
|
||||
|
||||
|
||||
class MockModel(KernelBaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
def test_structured_outputs_transform():
|
||||
"""Test the structured_outputs_transform function."""
|
||||
|
||||
service = MockChatCompletionService(ai_model_id="test_model")
|
||||
prompt_execution_settings = PromptExecutionSettings()
|
||||
|
||||
transform = structured_outputs_transform(
|
||||
target_structure=MockModel,
|
||||
service=service,
|
||||
prompt_execution_settings=prompt_execution_settings,
|
||||
)
|
||||
|
||||
assert isinstance(transform, Callable)
|
||||
|
||||
|
||||
def test_structured_outputs_transform_original_settings_not_changed():
|
||||
"""Test the structured_outputs_transform function with original settings not changed."""
|
||||
|
||||
service = MockChatCompletionService(ai_model_id="test_model")
|
||||
prompt_execution_settings = PromptExecutionSettings(
|
||||
extension_data={"test_key": "test_value"},
|
||||
)
|
||||
|
||||
_ = structured_outputs_transform(
|
||||
target_structure=MockModel,
|
||||
service=service,
|
||||
prompt_execution_settings=prompt_execution_settings,
|
||||
)
|
||||
|
||||
assert not hasattr(prompt_execution_settings, "response_format")
|
||||
assert prompt_execution_settings.extension_data["test_key"] == "test_value"
|
||||
|
||||
|
||||
def test_structured_outputs_transform_unsupported_service():
|
||||
"""Test the structured_outputs_transform function with unsupported service."""
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
MockChatCompletionService, "get_prompt_execution_settings_class"
|
||||
) as mock_get_prompt_execution_settings_class,
|
||||
pytest.raises(ValueError),
|
||||
):
|
||||
mock_get_prompt_execution_settings_class.return_value = PromptExecutionSettings
|
||||
|
||||
service = MockChatCompletionService(ai_model_id="test_model")
|
||||
prompt_execution_settings = PromptExecutionSettings()
|
||||
|
||||
_ = structured_outputs_transform(MockModel, service, prompt_execution_settings)
|
||||
|
||||
|
||||
async def test_structured_outputs_transform_invoke():
|
||||
"""Test the structured_outputs_transform function and invoke the transform."""
|
||||
mock_model = MockModel(name="John Doe", age=30)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
MockChatCompletionService, "get_chat_message_content", new_callable=AsyncMock
|
||||
) as mock_get_chat_message_content,
|
||||
):
|
||||
mock_get_chat_message_content.return_value = ChatMessageContent(
|
||||
role="assistant", content=mock_model.model_dump_json()
|
||||
)
|
||||
|
||||
service = MockChatCompletionService(ai_model_id="test_model")
|
||||
prompt_execution_settings = PromptExecutionSettings()
|
||||
|
||||
transform = structured_outputs_transform(
|
||||
target_structure=MockModel,
|
||||
service=service,
|
||||
prompt_execution_settings=prompt_execution_settings,
|
||||
)
|
||||
|
||||
result = await transform(ChatMessageContent(role="user", content="name is John Doe and age is 30"))
|
||||
|
||||
assert isinstance(result, MockModel)
|
||||
assert result == mock_model
|
||||
|
||||
mock_get_chat_message_content.assert_called_once()
|
||||
assert len(mock_get_chat_message_content.call_args[0][0].messages) == 2
|
||||
|
||||
|
||||
async def test_structured_outputs_transform_invoke_with_messages():
|
||||
"""Test the structured_outputs_transform function and invoke the transform with messages."""
|
||||
mock_model = MockModel(name="John Doe", age=30)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
MockChatCompletionService, "get_chat_message_content", new_callable=AsyncMock
|
||||
) as mock_get_chat_message_content,
|
||||
):
|
||||
mock_get_chat_message_content.return_value = ChatMessageContent(
|
||||
role="assistant", content=mock_model.model_dump_json()
|
||||
)
|
||||
|
||||
service = MockChatCompletionService(ai_model_id="test_model")
|
||||
prompt_execution_settings = PromptExecutionSettings()
|
||||
|
||||
transform = structured_outputs_transform(
|
||||
target_structure=MockModel,
|
||||
service=service,
|
||||
prompt_execution_settings=prompt_execution_settings,
|
||||
)
|
||||
|
||||
result = await transform([
|
||||
ChatMessageContent(role="user", content="name is John Doe"),
|
||||
ChatMessageContent(role="user", content="age is 30"),
|
||||
])
|
||||
|
||||
assert isinstance(result, MockModel)
|
||||
assert result == mock_model
|
||||
|
||||
mock_get_chat_message_content.assert_called_once()
|
||||
assert len(mock_get_chat_message_content.call_args[0][0].messages) == 3
|
||||
|
||||
|
||||
async def test_structured_outputs_transform_invoke_unsupported_type():
|
||||
"""Test the structured_outputs_transform function and invoke the transform with messages of unsupported type."""
|
||||
|
||||
service = MockChatCompletionService(ai_model_id="test_model")
|
||||
prompt_execution_settings = PromptExecutionSettings()
|
||||
|
||||
transform = structured_outputs_transform(
|
||||
target_structure=MockModel,
|
||||
service=service,
|
||||
prompt_execution_settings=prompt_execution_settings,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_ = await transform("unsupported type")
|
||||
@@ -0,0 +1,202 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.orchestration.orchestration_base import DefaultTypeAlias, OrchestrationResult
|
||||
from semantic_kernel.agents.orchestration.sequential import SequentialOrchestration
|
||||
from semantic_kernel.agents.runtime.in_process.in_process_runtime import InProcessRuntime
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from tests.unit.agents.orchestration.conftest import MockAgent, MockAgentWithException, MockRuntime
|
||||
|
||||
|
||||
async def test_prepare():
|
||||
"""Test the prepare method of the SequentialOrchestration."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = MockRuntime()
|
||||
|
||||
package_path = "semantic_kernel.agents.orchestration.sequential"
|
||||
with (
|
||||
patch(f"{package_path}.SequentialOrchestration._start"),
|
||||
patch(f"{package_path}.SequentialAgentActor.register") as mock_agent_actor_register,
|
||||
patch(f"{package_path}.CollectionActor.register") as mock_collection_actor_register,
|
||||
patch.object(runtime, "add_subscription") as mock_add_subscription,
|
||||
):
|
||||
orchestration = SequentialOrchestration(members=[agent_a, agent_b])
|
||||
await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
assert mock_agent_actor_register.call_count == 2
|
||||
assert mock_collection_actor_register.call_count == 1
|
||||
assert mock_add_subscription.call_count == 0
|
||||
|
||||
|
||||
async def test_invoke():
|
||||
"""Test the invoke method of the SequentialOrchestration."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = SequentialOrchestration(members=[agent_a, agent_b])
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
result = await orchestration_result.get(1.0)
|
||||
|
||||
assert isinstance(orchestration_result, OrchestrationResult)
|
||||
assert isinstance(result, ChatMessageContent)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
async def test_invoke_with_response_callback():
|
||||
"""Test the invoke method of the SequentialOrchestration with a response callback."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
responses: list[DefaultTypeAlias] = []
|
||||
try:
|
||||
orchestration = SequentialOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
agent_response_callback=lambda x: responses.append(x),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
|
||||
assert len(responses) == 2
|
||||
assert all(isinstance(item, ChatMessageContent) for item in responses)
|
||||
assert all(item.content == "mock_response" for item in responses)
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
async def test_invoke_with_streaming_response_callback():
|
||||
"""Test the invoke method of the SequentialOrchestration with a streaming response callback."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
responses: dict[str, list[StreamingChatMessageContent]] = {}
|
||||
try:
|
||||
orchestration = SequentialOrchestration(
|
||||
members=[agent_a, agent_b],
|
||||
streaming_agent_response_callback=lambda x, _: responses.setdefault(x.name, []).append(x),
|
||||
)
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
await orchestration_result.get(1.0)
|
||||
|
||||
assert len(responses[agent_a.name]) == 2
|
||||
assert len(responses[agent_b.name]) == 2
|
||||
|
||||
assert all(isinstance(item, StreamingChatMessageContent) for item in responses[agent_a.name])
|
||||
assert all(isinstance(item, StreamingChatMessageContent) for item in responses[agent_b.name])
|
||||
|
||||
agent_a_response = sum(responses[agent_a.name][1:], responses[agent_a.name][0])
|
||||
assert agent_a_response.content == "mock_response"
|
||||
agent_b_response = sum(responses[agent_b.name][1:], responses[agent_b.name][0])
|
||||
assert agent_b_response.content == "mock_response"
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.10 doesn't bound the original function provided to the wraps argument of the patch object.",
|
||||
)
|
||||
async def test_invoke_cancel_before_completion():
|
||||
"""Test the invoke method of the SequentialOrchestration with cancellation before completion."""
|
||||
with (
|
||||
patch.object(MockAgent, "invoke_stream", wraps=MockAgent.invoke_stream, autospec=True) as mock_invoke_stream,
|
||||
):
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = SequentialOrchestration(members=[agent_a, agent_b])
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
# Cancel while the first agent is processing
|
||||
await asyncio.sleep(0.05)
|
||||
orchestration_result.cancel()
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
assert mock_invoke_stream.call_count == 1
|
||||
|
||||
|
||||
async def test_invoke_cancel_after_completion():
|
||||
"""Test the invoke method of the SequentialOrchestration with cancellation after completion."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = SequentialOrchestration(members=[agent_a, agent_b])
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
# Wait for the orchestration to complete
|
||||
await orchestration_result.get(1.0)
|
||||
|
||||
with pytest.raises(RuntimeError, match="The invocation has already been completed."):
|
||||
orchestration_result.cancel()
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
async def test_invoke_with_double_get_result():
|
||||
"""Test the invoke method of the SequentialOrchestration with double get result."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgent()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = SequentialOrchestration(members=[agent_a, agent_b])
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
# Get result before completion
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await orchestration_result.get(0.1)
|
||||
# The invocation should still be in progress and getting the result again should not raise an error
|
||||
result = await orchestration_result.get(1.0)
|
||||
|
||||
assert isinstance(result, ChatMessageContent)
|
||||
assert result.content == "mock_response"
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
|
||||
async def test_invoke_with_agent_raising_exception():
|
||||
"""Test the invoke method of the SequentialOrchestration with an agent raising an exception."""
|
||||
agent_a = MockAgent()
|
||||
agent_b = MockAgentWithException()
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
try:
|
||||
orchestration = SequentialOrchestration(members=[agent_a, agent_b])
|
||||
orchestration_result = await orchestration.invoke(task="test_message", runtime=runtime)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Mock agent exception"):
|
||||
await orchestration_result.get(1.0)
|
||||
assert orchestration_result.exception is not None
|
||||
finally:
|
||||
await runtime.stop_when_idle()
|
||||
@@ -0,0 +1,142 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from semantic_kernel.agents.runtime.core.serialization import (
|
||||
JSON_DATA_CONTENT_TYPE,
|
||||
DataclassJsonMessageSerializer,
|
||||
MessageSerializer,
|
||||
SerializationRegistry,
|
||||
try_get_known_serializers_for_type,
|
||||
)
|
||||
|
||||
|
||||
class PydanticMessage(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class NestingPydanticMessage(BaseModel):
|
||||
message: str
|
||||
nested: PydanticMessage
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataclassMessage:
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class NestingDataclassMessage:
|
||||
message: str
|
||||
nested: DataclassMessage
|
||||
|
||||
|
||||
@dataclass
|
||||
class NestingPydanticDataclassMessage:
|
||||
message: str
|
||||
nested: PydanticMessage
|
||||
|
||||
|
||||
def test_pydantic() -> None:
|
||||
serde = SerializationRegistry()
|
||||
serde.add_serializer(try_get_known_serializers_for_type(PydanticMessage))
|
||||
|
||||
message = PydanticMessage(message="hello")
|
||||
name = serde.type_name(message)
|
||||
json = serde.serialize(message, type_name=name, data_content_type=JSON_DATA_CONTENT_TYPE)
|
||||
assert name == "PydanticMessage"
|
||||
assert json == b'{"message":"hello"}'
|
||||
deserialized = serde.deserialize(json, type_name=name, data_content_type=JSON_DATA_CONTENT_TYPE)
|
||||
assert deserialized == message
|
||||
|
||||
|
||||
def test_nested_pydantic() -> None:
|
||||
serde = SerializationRegistry()
|
||||
serde.add_serializer(try_get_known_serializers_for_type(NestingPydanticMessage))
|
||||
|
||||
message = NestingPydanticMessage(message="hello", nested=PydanticMessage(message="world"))
|
||||
name = serde.type_name(message)
|
||||
json = serde.serialize(message, type_name=name, data_content_type=JSON_DATA_CONTENT_TYPE)
|
||||
assert json == b'{"message":"hello","nested":{"message":"world"}}'
|
||||
deserialized = serde.deserialize(json, type_name=name, data_content_type=JSON_DATA_CONTENT_TYPE)
|
||||
assert deserialized == message
|
||||
|
||||
|
||||
def test_dataclass() -> None:
|
||||
serde = SerializationRegistry()
|
||||
serde.add_serializer(try_get_known_serializers_for_type(DataclassMessage))
|
||||
|
||||
message = DataclassMessage(message="hello")
|
||||
name = serde.type_name(message)
|
||||
json = serde.serialize(message, type_name=name, data_content_type=JSON_DATA_CONTENT_TYPE)
|
||||
assert json == b'{"message": "hello"}'
|
||||
deserialized = serde.deserialize(json, type_name=name, data_content_type=JSON_DATA_CONTENT_TYPE)
|
||||
assert deserialized == message
|
||||
|
||||
|
||||
def test_nesting_dataclass_dataclass() -> None:
|
||||
serde = SerializationRegistry()
|
||||
with pytest.raises(ValueError):
|
||||
serde.add_serializer(try_get_known_serializers_for_type(NestingDataclassMessage))
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataclassNestedUnionSyntaxOldMessage:
|
||||
message: str | int
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataclassNestedUnionSyntaxNewMessage:
|
||||
message: str | int
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cls", [DataclassNestedUnionSyntaxOldMessage, DataclassNestedUnionSyntaxNewMessage])
|
||||
def test_nesting_union_old_syntax_dataclass(
|
||||
cls: type[DataclassNestedUnionSyntaxOldMessage | DataclassNestedUnionSyntaxNewMessage],
|
||||
) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_serializer = DataclassJsonMessageSerializer(cls)
|
||||
|
||||
|
||||
def test_nesting_dataclass_pydantic() -> None:
|
||||
serde = SerializationRegistry()
|
||||
with pytest.raises(ValueError):
|
||||
serde.add_serializer(try_get_known_serializers_for_type(NestingPydanticDataclassMessage))
|
||||
|
||||
|
||||
def test_invalid_type() -> None:
|
||||
serde = SerializationRegistry()
|
||||
try:
|
||||
serde.add_serializer(try_get_known_serializers_for_type(str))
|
||||
except ValueError as e:
|
||||
assert str(e) == "Unsupported type <class 'str'>"
|
||||
|
||||
|
||||
def test_custom_type() -> None:
|
||||
serde = SerializationRegistry()
|
||||
|
||||
class CustomStringTypeSerializer(MessageSerializer[str]):
|
||||
@property
|
||||
def data_content_type(self) -> str:
|
||||
return "str"
|
||||
|
||||
@property
|
||||
def type_name(self) -> str:
|
||||
return "custom_str"
|
||||
|
||||
def deserialize(self, payload: bytes) -> str:
|
||||
message = payload.decode("utf-8")
|
||||
return message[1:-1]
|
||||
|
||||
def serialize(self, message: str) -> bytes:
|
||||
return f'"{message}"'.encode()
|
||||
|
||||
serde.add_serializer(CustomStringTypeSerializer())
|
||||
message = "hello"
|
||||
json = serde.serialize(message, type_name="custom_str", data_content_type="str")
|
||||
assert json == b'"hello"'
|
||||
deserialized = serde.deserialize(json, type_name="custom_str", data_content_type="str")
|
||||
assert deserialized == message
|
||||
@@ -0,0 +1,397 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from asyncio import Event
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult
|
||||
|
||||
from semantic_kernel.agents.runtime.core import CoreAgentId
|
||||
from semantic_kernel.agents.runtime.core.agent_type import CoreAgentType
|
||||
from semantic_kernel.agents.runtime.core.base_agent import BaseAgent
|
||||
from semantic_kernel.agents.runtime.core.message_context import MessageContext
|
||||
from semantic_kernel.agents.runtime.core.routed_agent import RoutedAgent, event, message_handler
|
||||
from semantic_kernel.agents.runtime.core.serialization import try_get_known_serializers_for_type
|
||||
from semantic_kernel.agents.runtime.core.topic import TopicId
|
||||
from semantic_kernel.agents.runtime.in_process.agent_instantiation_context import AgentInstantiationContext
|
||||
from semantic_kernel.agents.runtime.in_process.default_subscription import default_subscription, type_subscription
|
||||
from semantic_kernel.agents.runtime.in_process.default_topic import DefaultTopicId
|
||||
from semantic_kernel.agents.runtime.in_process.in_process_runtime import InProcessRuntime
|
||||
from semantic_kernel.agents.runtime.in_process.type_subscription import TypeSubscription
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageType: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class CascadingMessageType:
|
||||
round: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContentMessage:
|
||||
content: str
|
||||
|
||||
|
||||
class LoopbackAgent(RoutedAgent):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("A loop back agent.")
|
||||
self.num_calls = 0
|
||||
self.received_messages: list[Any] = []
|
||||
self.event = Event()
|
||||
|
||||
@message_handler
|
||||
async def on_new_message(
|
||||
self, message: MessageType | ContentMessage, ctx: MessageContext
|
||||
) -> MessageType | ContentMessage:
|
||||
self.num_calls += 1
|
||||
self.received_messages.append(message)
|
||||
self.event.set()
|
||||
return message
|
||||
|
||||
|
||||
@default_subscription
|
||||
class LoopbackAgentWithDefaultSubscription(LoopbackAgent): ...
|
||||
|
||||
|
||||
@default_subscription
|
||||
class CascadingAgent(RoutedAgent):
|
||||
def __init__(self, max_rounds: int) -> None:
|
||||
super().__init__("A cascading agent.")
|
||||
self.num_calls = 0
|
||||
self.max_rounds = max_rounds
|
||||
|
||||
@message_handler
|
||||
async def on_new_message(self, message: CascadingMessageType, ctx: MessageContext) -> None:
|
||||
self.num_calls += 1
|
||||
if message.round == self.max_rounds:
|
||||
return
|
||||
await self.publish_message(CascadingMessageType(round=message.round + 1), topic_id=DefaultTopicId())
|
||||
|
||||
|
||||
class NoopAgent(BaseAgent):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("A no op agent")
|
||||
|
||||
async def on_message_impl(self, message: Any, ctx: MessageContext) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MyTestExporter(SpanExporter):
|
||||
def __init__(self) -> None:
|
||||
self.exported_spans: list[ReadableSpan] = []
|
||||
|
||||
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
||||
self.exported_spans.extend(spans)
|
||||
return SpanExportResult.SUCCESS
|
||||
|
||||
def shutdown(self) -> None:
|
||||
pass
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clears the list of exported spans."""
|
||||
self.exported_spans.clear()
|
||||
|
||||
def get_exported_spans(self) -> list[ReadableSpan]:
|
||||
"""Returns the list of exported spans."""
|
||||
return self.exported_spans
|
||||
|
||||
|
||||
def get_test_tracer_provider(exporter: MyTestExporter) -> TracerProvider:
|
||||
tracer_provider = TracerProvider()
|
||||
tracer_provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||||
return tracer_provider
|
||||
|
||||
|
||||
test_exporter = MyTestExporter()
|
||||
|
||||
|
||||
class FakeAgent(BaseAgent):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("A fake agent")
|
||||
|
||||
async def on_message_impl(self, message: Any, ctx: MessageContext) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tracer_provider() -> TracerProvider:
|
||||
test_exporter.clear()
|
||||
return get_test_tracer_provider(test_exporter)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_type_register_factory() -> None:
|
||||
runtime = InProcessRuntime()
|
||||
|
||||
def agent_factory() -> NoopAgent:
|
||||
id = AgentInstantiationContext.current_agent_id()
|
||||
assert id == CoreAgentId("name1", "default")
|
||||
agent = NoopAgent()
|
||||
assert agent.id == id
|
||||
return agent
|
||||
|
||||
await runtime.register_factory(type=CoreAgentType("name1"), agent_factory=agent_factory, expected_class=NoopAgent)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
# This should fail because the expected class does not match the actual class.
|
||||
await runtime.register_factory(
|
||||
type=CoreAgentType("name1"),
|
||||
agent_factory=agent_factory, # type: ignore
|
||||
expected_class=FakeAgent,
|
||||
)
|
||||
|
||||
# Without expected_class, no error.
|
||||
await runtime.register_factory(type=CoreAgentType("name2"), agent_factory=agent_factory)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_type_must_be_unique() -> None:
|
||||
runtime = InProcessRuntime()
|
||||
|
||||
def agent_factory() -> NoopAgent:
|
||||
id = AgentInstantiationContext.current_agent_id()
|
||||
assert id == CoreAgentId("name1", "default")
|
||||
agent = NoopAgent()
|
||||
assert agent.id == id
|
||||
return agent
|
||||
|
||||
await NoopAgent.register(runtime, "name1", agent_factory)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await runtime.register_factory(
|
||||
type=CoreAgentType("name1"), agent_factory=agent_factory, expected_class=NoopAgent
|
||||
)
|
||||
|
||||
await runtime.register_factory(type=CoreAgentType("name2"), agent_factory=agent_factory, expected_class=NoopAgent)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_receives_publish(tracer_provider: TracerProvider) -> None:
|
||||
runtime = InProcessRuntime(tracer_provider=tracer_provider)
|
||||
|
||||
runtime.add_message_serializer(try_get_known_serializers_for_type(MessageType))
|
||||
await runtime.register_factory(
|
||||
type=CoreAgentType("name"), agent_factory=lambda: LoopbackAgent(), expected_class=LoopbackAgent
|
||||
)
|
||||
await runtime.add_subscription(TypeSubscription("default", "name"))
|
||||
|
||||
runtime.start()
|
||||
await runtime.publish_message(MessageType(), topic_id=TopicId("default", "default"))
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
# Agent in default namespace should have received the message
|
||||
long_running_agent = await runtime.try_get_underlying_agent_instance(
|
||||
CoreAgentId("name", "default"),
|
||||
type=LoopbackAgent,
|
||||
)
|
||||
assert long_running_agent.num_calls == 1
|
||||
|
||||
# Agent in other namespace should not have received the message
|
||||
other_long_running_agent: LoopbackAgent = await runtime.try_get_underlying_agent_instance(
|
||||
CoreAgentId("name", key="other"), type=LoopbackAgent
|
||||
)
|
||||
assert other_long_running_agent.num_calls == 0
|
||||
|
||||
await runtime.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_receives_publish_with_construction(caplog: pytest.LogCaptureFixture) -> None:
|
||||
runtime = InProcessRuntime()
|
||||
|
||||
runtime.add_message_serializer(try_get_known_serializers_for_type(MessageType))
|
||||
|
||||
async def agent_factory() -> LoopbackAgent:
|
||||
raise ValueError("test")
|
||||
|
||||
await runtime.register_factory(
|
||||
type=CoreAgentType("name"), agent_factory=agent_factory, expected_class=LoopbackAgent
|
||||
)
|
||||
await runtime.add_subscription(TypeSubscription("default", "name"))
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
runtime.start()
|
||||
await runtime.publish_message(MessageType(), topic_id=TopicId("default", "default"))
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
# Check if logger has the exception.
|
||||
assert any("Error constructing agent" in e.message for e in caplog.records)
|
||||
|
||||
await runtime.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_receives_publish_cascade() -> None:
|
||||
num_agents = 5
|
||||
num_initial_messages = 5
|
||||
max_rounds = 5
|
||||
total_num_calls_expected = 0
|
||||
for i in range(0, max_rounds):
|
||||
total_num_calls_expected += num_initial_messages * ((num_agents - 1) ** i)
|
||||
|
||||
runtime = InProcessRuntime()
|
||||
|
||||
# Register agents
|
||||
for i in range(num_agents):
|
||||
await CascadingAgent.register(runtime, f"name{i}", lambda: CascadingAgent(max_rounds))
|
||||
|
||||
runtime.start()
|
||||
|
||||
# Publish messages
|
||||
for _ in range(num_initial_messages):
|
||||
await runtime.publish_message(CascadingMessageType(round=1), DefaultTopicId())
|
||||
|
||||
# Process until idle.
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
# Check that each agent received the correct number of messages.
|
||||
for i in range(num_agents):
|
||||
agent = await runtime.try_get_underlying_agent_instance(CoreAgentId(f"name{i}", "default"), CascadingAgent)
|
||||
assert agent.num_calls == total_num_calls_expected
|
||||
|
||||
await runtime.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_factory_explicit_name() -> None:
|
||||
runtime = InProcessRuntime()
|
||||
|
||||
await LoopbackAgent.register(runtime, "name", LoopbackAgent)
|
||||
await runtime.add_subscription(TypeSubscription("default", "name"))
|
||||
|
||||
runtime.start()
|
||||
agent_id = CoreAgentId("name", key="default")
|
||||
topic_id = TopicId("default", "default")
|
||||
await runtime.publish_message(MessageType(), topic_id=topic_id)
|
||||
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
# Agent in default namespace should have received the message
|
||||
long_running_agent = await runtime.try_get_underlying_agent_instance(agent_id, type=LoopbackAgent)
|
||||
assert long_running_agent.num_calls == 1
|
||||
|
||||
# Agent in other namespace should not have received the message
|
||||
other_long_running_agent: LoopbackAgent = await runtime.try_get_underlying_agent_instance(
|
||||
CoreAgentId("name", key="other"), type=LoopbackAgent
|
||||
)
|
||||
assert other_long_running_agent.num_calls == 0
|
||||
|
||||
await runtime.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_subscription() -> None:
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
await LoopbackAgentWithDefaultSubscription.register(runtime, "name", LoopbackAgentWithDefaultSubscription)
|
||||
|
||||
agent_id = CoreAgentId("name", key="default")
|
||||
await runtime.publish_message(MessageType(), topic_id=DefaultTopicId())
|
||||
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
long_running_agent = await runtime.try_get_underlying_agent_instance(
|
||||
agent_id, type=LoopbackAgentWithDefaultSubscription
|
||||
)
|
||||
assert long_running_agent.num_calls == 1
|
||||
|
||||
other_long_running_agent = await runtime.try_get_underlying_agent_instance(
|
||||
CoreAgentId("name", key="other"), type=LoopbackAgentWithDefaultSubscription
|
||||
)
|
||||
assert other_long_running_agent.num_calls == 0
|
||||
|
||||
await runtime.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_type_subscription() -> None:
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
@type_subscription(topic_type="Other")
|
||||
class LoopbackAgentWithSubscription(LoopbackAgent): ...
|
||||
|
||||
await LoopbackAgentWithSubscription.register(runtime, "name", LoopbackAgentWithSubscription)
|
||||
|
||||
agent_id = CoreAgentId("name", key="default")
|
||||
await runtime.publish_message(MessageType(), topic_id=TopicId("Other", "default"))
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
long_running_agent = await runtime.try_get_underlying_agent_instance(agent_id, type=LoopbackAgentWithSubscription)
|
||||
assert long_running_agent.num_calls == 1
|
||||
|
||||
other_long_running_agent = await runtime.try_get_underlying_agent_instance(
|
||||
CoreAgentId("name", key="other"), type=LoopbackAgentWithSubscription
|
||||
)
|
||||
assert other_long_running_agent.num_calls == 0
|
||||
|
||||
await runtime.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_subscription_publish_to_other_source() -> None:
|
||||
runtime = InProcessRuntime()
|
||||
runtime.start()
|
||||
|
||||
await LoopbackAgentWithDefaultSubscription.register(runtime, "name", LoopbackAgentWithDefaultSubscription)
|
||||
|
||||
agent_id = CoreAgentId("name", key="default")
|
||||
await runtime.publish_message(MessageType(), topic_id=DefaultTopicId(source="other"))
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
long_running_agent = await runtime.try_get_underlying_agent_instance(
|
||||
agent_id, type=LoopbackAgentWithDefaultSubscription
|
||||
)
|
||||
assert long_running_agent.num_calls == 0
|
||||
|
||||
other_long_running_agent = await runtime.try_get_underlying_agent_instance(
|
||||
CoreAgentId("name", key="other"), type=LoopbackAgentWithDefaultSubscription
|
||||
)
|
||||
assert other_long_running_agent.num_calls == 1
|
||||
|
||||
await runtime.close()
|
||||
|
||||
|
||||
@default_subscription
|
||||
class FailingAgent(RoutedAgent):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("A failing agent.")
|
||||
|
||||
@event
|
||||
async def on_new_message_event(self, message: MessageType, ctx: MessageContext) -> None:
|
||||
raise ValueError("Test exception")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_handler_exception_propagates() -> None:
|
||||
runtime = InProcessRuntime(ignore_unhandled_exceptions=False)
|
||||
await FailingAgent.register(runtime, "name", FailingAgent)
|
||||
|
||||
with pytest.raises(ValueError, match="Test exception"):
|
||||
runtime.start()
|
||||
await runtime.publish_message(MessageType(), topic_id=DefaultTopicId())
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
await runtime.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_handler_exception_multi_message() -> None:
|
||||
runtime = InProcessRuntime(ignore_unhandled_exceptions=False)
|
||||
await FailingAgent.register(runtime, "name", FailingAgent)
|
||||
|
||||
with pytest.raises(ValueError, match="Test exception"):
|
||||
runtime.start()
|
||||
await runtime.publish_message(MessageType(), topic_id=DefaultTopicId())
|
||||
await runtime.publish_message(MessageType(), topic_id=DefaultTopicId())
|
||||
await runtime.publish_message(MessageType(), topic_id=DefaultTopicId())
|
||||
await runtime.stop_when_idle()
|
||||
|
||||
await runtime.close()
|
||||
@@ -0,0 +1,419 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import sys
|
||||
import uuid
|
||||
from typing import ClassVar
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.agent import AGENT_TYPE_REGISTRY, AgentRegistry, DeclarativeSpecMixin, register_agent_type
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentInitializationException
|
||||
from semantic_kernel.kernel import Kernel
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # pragma: no cover
|
||||
|
||||
from semantic_kernel.agents import Agent
|
||||
from semantic_kernel.agents.channels.agent_channel import AgentChannel
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
|
||||
|
||||
class MockChatHistory:
|
||||
"""Minimal mock for ChatHistory to hold messages."""
|
||||
|
||||
def __init__(self, messages=None):
|
||||
self.messages = messages if messages is not None else []
|
||||
|
||||
|
||||
class MockChannel(AgentChannel):
|
||||
"""Mock channel for testing get_channel_keys and create_channel."""
|
||||
|
||||
|
||||
class MockAgent(Agent):
|
||||
"""A mock agent for testing purposes."""
|
||||
|
||||
channel_type: ClassVar[type[AgentChannel]] = MockChannel
|
||||
|
||||
def __init__(self, name: str = "Test-Agent", description: str = "A test agent", id: str = None):
|
||||
args = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
}
|
||||
if id is not None:
|
||||
args["id"] = id
|
||||
super().__init__(**args)
|
||||
|
||||
async def create_channel(self) -> AgentChannel:
|
||||
return AsyncMock(spec=AgentChannel)
|
||||
|
||||
@override
|
||||
async def get_response(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
@override
|
||||
async def invoke(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
@override
|
||||
async def invoke_stream(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MockAgentWithoutChannelType(MockAgent):
|
||||
channel_type = None
|
||||
|
||||
|
||||
async def test_agent_initialization():
|
||||
name = "TestAgent"
|
||||
description = "A test agent"
|
||||
id_value = str(uuid.uuid4())
|
||||
|
||||
agent = MockAgent(name=name, description=description, id=id_value)
|
||||
|
||||
assert agent.name == name
|
||||
assert agent.description == description
|
||||
assert agent.id == id_value
|
||||
|
||||
|
||||
async def test_agent_default_id():
|
||||
agent = MockAgent()
|
||||
|
||||
assert agent.id is not None
|
||||
assert isinstance(uuid.UUID(agent.id), uuid.UUID)
|
||||
|
||||
|
||||
def test_get_channel_keys():
|
||||
agent = MockAgent()
|
||||
keys = agent.get_channel_keys()
|
||||
|
||||
assert len(list(keys)) == 1, "Should return a single key"
|
||||
|
||||
|
||||
async def test_create_channel():
|
||||
agent = MockAgent()
|
||||
channel = await agent.create_channel()
|
||||
|
||||
assert isinstance(channel, AgentChannel)
|
||||
|
||||
|
||||
async def test_agent_equality():
|
||||
id_value = str(uuid.uuid4())
|
||||
|
||||
agent1 = MockAgent(name="TestAgent", description="A test agent", id=id_value)
|
||||
agent2 = MockAgent(name="TestAgent", description="A test agent", id=id_value)
|
||||
|
||||
assert agent1 == agent2
|
||||
|
||||
agent3 = MockAgent(name="TestAgent", description="A different description", id=id_value)
|
||||
assert agent1 != agent3
|
||||
|
||||
agent4 = MockAgent(name="AnotherAgent", description="A test agent", id=id_value)
|
||||
assert agent1 != agent4
|
||||
|
||||
|
||||
async def test_agent_equality_different_type():
|
||||
agent = MockAgent(name="TestAgent", description="A test agent", id=str(uuid.uuid4()))
|
||||
non_agent = "Not an agent"
|
||||
|
||||
assert agent != non_agent
|
||||
|
||||
|
||||
async def test_agent_hash():
|
||||
id_value = str(uuid.uuid4())
|
||||
|
||||
agent1 = MockAgent(name="TestAgent", description="A test agent", id=id_value)
|
||||
agent2 = MockAgent(name="TestAgent", description="A test agent", id=id_value)
|
||||
|
||||
assert hash(agent1) == hash(agent2)
|
||||
|
||||
agent3 = MockAgent(name="TestAgent", description="A different description", id=id_value)
|
||||
assert hash(agent1) != hash(agent3)
|
||||
|
||||
|
||||
def test_get_channel_keys_no_channel_type():
|
||||
agent = MockAgentWithoutChannelType()
|
||||
with pytest.raises(NotImplementedError):
|
||||
list(agent.get_channel_keys())
|
||||
|
||||
|
||||
def test_merge_arguments_both_none():
|
||||
agent = MockAgent()
|
||||
merged = agent._merge_arguments(None)
|
||||
assert isinstance(merged, KernelArguments)
|
||||
assert len(merged) == 0, "If both arguments are None, should return an empty KernelArguments object"
|
||||
|
||||
|
||||
def test_merge_arguments_agent_none_override_not_none():
|
||||
agent = MockAgent()
|
||||
override = KernelArguments(settings={"key": "override"}, param1="val1")
|
||||
|
||||
merged = agent._merge_arguments(override)
|
||||
assert merged is override, "If agent.arguments is None, just return override_args"
|
||||
|
||||
|
||||
def test_merge_arguments_override_none_agent_not_none():
|
||||
agent = MockAgent()
|
||||
agent.arguments = KernelArguments(settings={"key": "base"}, param1="baseVal")
|
||||
|
||||
merged = agent._merge_arguments(None)
|
||||
assert merged is agent.arguments, "If override_args is None, should return the agent's arguments"
|
||||
|
||||
|
||||
def test_merge_arguments_both_not_none():
|
||||
agent = MockAgent()
|
||||
agent.arguments = KernelArguments(settings={"key1": "val1", "common": "base"}, param1="baseVal")
|
||||
override = KernelArguments(settings={"key2": "override_val", "common": "override"}, param2="override_param")
|
||||
|
||||
merged = agent._merge_arguments(override)
|
||||
|
||||
assert merged.execution_settings["key1"] == "val1", "Should retain original setting from agent"
|
||||
assert merged.execution_settings["key2"] == "override_val", "Should include new setting from override"
|
||||
assert merged.execution_settings["common"] == "override", "Override should take precedence"
|
||||
|
||||
assert merged["param1"] == "baseVal", "Should retain base param from agent"
|
||||
assert merged["param2"] == "override_param", "Should include param from override"
|
||||
|
||||
|
||||
# region Declarative Spec tests
|
||||
|
||||
|
||||
class DummyPlugin:
|
||||
def __init__(self):
|
||||
self.functions = {"dummy_function": lambda: "result"}
|
||||
|
||||
def get(self, name):
|
||||
return self.functions.get(name)
|
||||
|
||||
|
||||
class DummyAgentSettings:
|
||||
azure_ai_search_connection_id = "test-conn-id"
|
||||
azure_ai_search_index_name = "test-index"
|
||||
|
||||
|
||||
class DummyKernel:
|
||||
def __init__(self):
|
||||
self.plugins = {}
|
||||
|
||||
def add_plugin(self, plugin):
|
||||
name = plugin.__class__.__name__
|
||||
self.plugins[name] = plugin
|
||||
|
||||
|
||||
async def test_resolve_placeholders_with_short_and_long_keys():
|
||||
class DummyDeclarativeSpec:
|
||||
@classmethod
|
||||
def resolve_placeholders(cls, yaml_str, settings=None, extras=None):
|
||||
import re
|
||||
|
||||
pattern = re.compile(r"\$\{([^}]+)\}")
|
||||
field_mapping = {
|
||||
"AzureAISearchConnectionId": "conn-123",
|
||||
"AzureAI:AzureAISearchConnectionId": "conn-123-override",
|
||||
}
|
||||
if extras:
|
||||
field_mapping.update(extras)
|
||||
|
||||
def replacer(match):
|
||||
full_key = match.group(1)
|
||||
section, _, key = full_key.partition(":")
|
||||
if section != "AzureAI":
|
||||
return match.group(0)
|
||||
return str(field_mapping.get(key) or field_mapping.get(full_key) or match.group(0))
|
||||
|
||||
return pattern.sub(replacer, yaml_str)
|
||||
|
||||
spec = "connection: ${AzureAI:AzureAISearchConnectionId}"
|
||||
resolved = DummyDeclarativeSpec.resolve_placeholders(spec)
|
||||
assert resolved == "connection: conn-123"
|
||||
|
||||
|
||||
async def test_validate_tools_succeeds_with_valid_plugin():
|
||||
class DummyDeclarativeSpec:
|
||||
@classmethod
|
||||
def _validate_tools(cls, tools_list, kernel):
|
||||
for tool in tools_list:
|
||||
tool_id = tool.get("id")
|
||||
if not tool_id or tool.get("type") != "function":
|
||||
continue
|
||||
plugin_name, function_name = tool_id.split(".")
|
||||
plugin = kernel.plugins.get(plugin_name)
|
||||
if not plugin:
|
||||
raise ValueError(f"Plugin {plugin_name} missing")
|
||||
if function_name not in plugin.functions:
|
||||
raise ValueError(f"Function {function_name} missing in plugin {plugin_name}")
|
||||
|
||||
kernel = DummyKernel()
|
||||
plugin = DummyPlugin()
|
||||
kernel.add_plugin(plugin)
|
||||
|
||||
DummyDeclarativeSpec._validate_tools([{"id": "DummyPlugin.dummy_function", "type": "function"}], kernel)
|
||||
|
||||
|
||||
async def test_validate_tools_raises_on_missing_plugin():
|
||||
class DummyDeclarativeSpec:
|
||||
@classmethod
|
||||
def _validate_tools(cls, tools_list, kernel):
|
||||
for tool in tools_list:
|
||||
tool_id = tool.get("id")
|
||||
if not tool_id or tool.get("type") != "function":
|
||||
continue
|
||||
plugin_name, function_name = tool_id.split(".")
|
||||
plugin = kernel.plugins.get(plugin_name)
|
||||
if not plugin:
|
||||
raise ValueError(f"Plugin {plugin_name} missing")
|
||||
if function_name not in plugin.functions:
|
||||
raise ValueError(f"Function {function_name} missing in plugin {plugin_name}")
|
||||
|
||||
kernel = DummyKernel()
|
||||
|
||||
with pytest.raises(ValueError, match="Plugin DummyPlugin missing"):
|
||||
DummyDeclarativeSpec._validate_tools([{"id": "DummyPlugin.dummy_function", "type": "function"}], kernel)
|
||||
|
||||
|
||||
def test_normalize_spec_fields_creates_kernel_and_extracts_fields():
|
||||
data = {
|
||||
"name": "TestAgent",
|
||||
"description": "An agent",
|
||||
"instructions": "Use this.",
|
||||
"model": {"options": {"temperature": 0.7}},
|
||||
}
|
||||
|
||||
fields, kernel = DeclarativeSpecMixin._normalize_spec_fields(data)
|
||||
assert isinstance(kernel, Kernel)
|
||||
assert fields["name"] == "TestAgent"
|
||||
assert isinstance(fields["arguments"], KernelArguments)
|
||||
|
||||
|
||||
def test_normalize_spec_fields_adds_plugins_to_kernel():
|
||||
plugin = DummyPlugin()
|
||||
data = {"name": "PluginAgent"}
|
||||
|
||||
_, kernel = DeclarativeSpecMixin._normalize_spec_fields(data, plugins=[plugin])
|
||||
assert "DummyPlugin" in kernel.plugins
|
||||
|
||||
|
||||
def test_normalize_spec_fields_parses_prompt_template_and_overwrites_instructions():
|
||||
data = {"name": "T", "prompt_template": {"template": "new instructions", "template_format": "semantic-kernel"}}
|
||||
|
||||
fields, _ = DeclarativeSpecMixin._normalize_spec_fields(data)
|
||||
assert fields["instructions"] == "new instructions"
|
||||
|
||||
|
||||
def test_validate_tools_success(custom_plugin_class):
|
||||
kernel = Kernel()
|
||||
kernel.add_plugin(custom_plugin_class())
|
||||
tools_list = [{"id": "CustomPlugin.getLightStatus", "type": "function"}]
|
||||
|
||||
DeclarativeSpecMixin._validate_tools(tools_list, kernel)
|
||||
|
||||
|
||||
def test_validate_tools_fails_on_invalid_format():
|
||||
kernel = Kernel()
|
||||
with pytest.raises(AgentInitializationException, match="format"):
|
||||
DeclarativeSpecMixin._validate_tools([{"id": "badformat", "type": "function"}], kernel)
|
||||
|
||||
|
||||
def test_validate_tools_fails_on_missing_plugin():
|
||||
kernel = Kernel()
|
||||
with pytest.raises(AgentInitializationException, match="not found in kernel"):
|
||||
DeclarativeSpecMixin._validate_tools([{"id": "MissingPlugin.foo", "type": "function"}], kernel)
|
||||
|
||||
|
||||
def test_validate_tools_fails_on_missing_function():
|
||||
plugin = DummyPlugin()
|
||||
kernel = Kernel()
|
||||
kernel.add_plugin(plugin)
|
||||
with pytest.raises(AgentInitializationException, match="not found in plugin"):
|
||||
DeclarativeSpecMixin._validate_tools([{"id": "DummyPlugin.bar", "type": "function"}], kernel)
|
||||
|
||||
|
||||
@register_agent_type("test_agent")
|
||||
class TestAgent(DeclarativeSpecMixin, Agent):
|
||||
@classmethod
|
||||
def resolve_placeholders(cls, yaml_str, settings=None, extras=None):
|
||||
return yaml_str
|
||||
|
||||
@classmethod
|
||||
async def _from_dict(cls, data, kernel, **kwargs):
|
||||
return cls(
|
||||
name=data.get("name"),
|
||||
description=data.get("description"),
|
||||
instructions=data.get("instructions"),
|
||||
kernel=kernel,
|
||||
)
|
||||
|
||||
async def get_response(self, messages, instructions_override=None):
|
||||
return "Test response"
|
||||
|
||||
async def invoke(self, messages, **kwargs):
|
||||
return "invoke result"
|
||||
|
||||
async def invoke_stream(self, messages, **kwargs):
|
||||
yield "streamed result"
|
||||
|
||||
|
||||
async def test_register_type_and_create_from_yaml_success():
|
||||
yaml_str = """
|
||||
type: test_agent
|
||||
name: TestAgent
|
||||
"""
|
||||
agent = await AgentRegistry.create_from_yaml(yaml_str)
|
||||
assert agent.__class__.__name__ == "TestAgent"
|
||||
|
||||
|
||||
async def test_create_from_yaml_missing_type():
|
||||
yaml_str = """
|
||||
name: InvalidAgent
|
||||
"""
|
||||
with pytest.raises(AgentInitializationException, match="Missing 'type'"):
|
||||
await AgentRegistry.create_from_yaml(yaml_str)
|
||||
|
||||
|
||||
async def test_create_from_yaml_unregistered_type():
|
||||
yaml_str = """
|
||||
type: nonexistent_agent
|
||||
"""
|
||||
# Ensure unregistered
|
||||
AGENT_TYPE_REGISTRY.pop("nonexistent_agent", None)
|
||||
with pytest.raises(AgentInitializationException, match="not registered"):
|
||||
await AgentRegistry.create_from_yaml(yaml_str)
|
||||
|
||||
|
||||
async def test_create_from_dict_success(test_agent_cls):
|
||||
data = {"type": "test_agent", "name": "FromDictAgent"}
|
||||
agent: TestAgent = await AgentRegistry.create_from_dict(data)
|
||||
assert agent.name == "FromDictAgent"
|
||||
assert type(agent).__name__ == "TestAgent"
|
||||
|
||||
|
||||
async def test_create_from_dict_missing_type():
|
||||
data = {"name": "NoType"}
|
||||
with pytest.raises(AgentInitializationException, match="Missing 'type'"):
|
||||
await AgentRegistry.create_from_dict(data)
|
||||
|
||||
|
||||
async def test_create_from_dict_type_not_supported():
|
||||
AGENT_TYPE_REGISTRY.pop("unknown", None)
|
||||
data = {"type": "unknown"}
|
||||
with pytest.raises(AgentInitializationException, match="not supported"):
|
||||
await AgentRegistry.create_from_dict(data)
|
||||
|
||||
|
||||
async def test_create_from_file_reads_and_creates(tmp_path, test_agent_cls):
|
||||
file_path = tmp_path / "spec.yaml"
|
||||
file_path.write_text("type: test_agent\nname: FileAgent\n", encoding="utf-8")
|
||||
|
||||
agent: TestAgent = await AgentRegistry.create_from_file(str(file_path))
|
||||
assert agent.name == "FileAgent"
|
||||
assert type(agent).__name__ == "TestAgent"
|
||||
|
||||
|
||||
async def test_create_from_file_raises_on_bad_path():
|
||||
with pytest.raises(AgentInitializationException, match="Failed to read agent spec file"):
|
||||
await AgentRegistry.create_from_file("/nonexistent/path/spec.yaml")
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,59 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from semantic_kernel.agents import Agent
|
||||
from semantic_kernel.agents.channels.agent_channel import AgentChannel
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
|
||||
|
||||
class MockAgentChannel(AgentChannel):
|
||||
async def receive(self, history: list[ChatMessageContent]) -> None:
|
||||
pass
|
||||
|
||||
async def invoke(self, agent: "Agent") -> AsyncIterable[ChatMessageContent]:
|
||||
yield ChatMessageContent(role=AuthorRole.SYSTEM, content="test message")
|
||||
|
||||
async def get_history(self) -> AsyncIterable[ChatMessageContent]:
|
||||
yield ChatMessageContent(role=AuthorRole.SYSTEM, content="test history message")
|
||||
|
||||
|
||||
async def test_receive():
|
||||
mock_channel = AsyncMock(spec=MockAgentChannel)
|
||||
|
||||
history = [
|
||||
ChatMessageContent(role=AuthorRole.SYSTEM, content="test message 1"),
|
||||
ChatMessageContent(role=AuthorRole.USER, content="test message 2"),
|
||||
]
|
||||
|
||||
await mock_channel.receive(history)
|
||||
mock_channel.receive.assert_called_once_with(history)
|
||||
|
||||
|
||||
async def test_invoke():
|
||||
mock_channel = AsyncMock(spec=MockAgentChannel)
|
||||
agent = AsyncMock()
|
||||
|
||||
async def async_generator():
|
||||
yield ChatMessageContent(role=AuthorRole.SYSTEM, content="test message")
|
||||
|
||||
mock_channel.invoke.return_value = async_generator()
|
||||
|
||||
async for message in mock_channel.invoke(agent):
|
||||
assert message.content == "test message"
|
||||
mock_channel.invoke.assert_called_once_with(agent)
|
||||
|
||||
|
||||
async def test_get_history():
|
||||
mock_channel = AsyncMock(spec=MockAgentChannel)
|
||||
|
||||
async def async_generator():
|
||||
yield ChatMessageContent(role=AuthorRole.SYSTEM, content="test history message")
|
||||
|
||||
mock_channel.get_history.return_value = async_generator()
|
||||
|
||||
async for message in mock_channel.get_history():
|
||||
assert message.content == "test history message"
|
||||
mock_channel.get_history.assert_called_once()
|
||||
@@ -0,0 +1,245 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest import mock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.channels.agent_channel import AgentChannel
|
||||
from semantic_kernel.agents.group_chat.agent_chat import AgentChat
|
||||
from semantic_kernel.agents.group_chat.broadcast_queue import ChannelReference
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentChatException
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_chat():
|
||||
return AgentChat()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent():
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.name = "TestAgent"
|
||||
return mock_agent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chat_message():
|
||||
mock_chat_message = MagicMock(spec=ChatMessageContent)
|
||||
mock_chat_message.role = "user"
|
||||
return mock_chat_message
|
||||
|
||||
|
||||
async def test_set_activity_or_throw_when_inactive(agent_chat):
|
||||
agent_chat._is_active = False
|
||||
agent_chat.set_activity_or_throw()
|
||||
assert agent_chat.is_active
|
||||
|
||||
|
||||
async def test_set_activity_or_throw_when_active(agent_chat):
|
||||
agent_chat._is_active = True
|
||||
with pytest.raises(Exception, match="Unable to proceed while another agent is active."):
|
||||
agent_chat.set_activity_or_throw()
|
||||
|
||||
|
||||
async def test_clear_activity_signal(agent_chat):
|
||||
agent_chat._is_active = True
|
||||
agent_chat.clear_activity_signal()
|
||||
assert not agent_chat.is_active
|
||||
|
||||
|
||||
async def test_get_messages_in_descending_order(agent_chat, chat_message):
|
||||
agent_chat.history.messages = [chat_message, chat_message, chat_message]
|
||||
messages = []
|
||||
async for message in agent_chat.get_messages_in_descending_order():
|
||||
messages.append(message)
|
||||
assert len(messages) == 3
|
||||
|
||||
|
||||
async def test_get_chat_messages_without_agent(agent_chat, chat_message):
|
||||
agent_chat.history.messages = [chat_message]
|
||||
with patch(
|
||||
"semantic_kernel.agents.group_chat.agent_chat.AgentChat.get_messages_in_descending_order",
|
||||
return_value=AsyncMock(),
|
||||
) as mock_get_messages:
|
||||
async for _ in agent_chat.get_chat_messages():
|
||||
pass
|
||||
mock_get_messages.assert_called_once()
|
||||
|
||||
|
||||
async def test_get_chat_messages_with_agent(agent_chat, agent, chat_message):
|
||||
agent_chat.channel_map[agent] = "test_channel"
|
||||
|
||||
mock_channel = mock.MagicMock(spec=AgentChannel)
|
||||
agent_chat.agent_channels["test_channel"] = mock_channel
|
||||
|
||||
with (
|
||||
patch("semantic_kernel.agents.group_chat.agent_chat.AgentChat._get_agent_hash", return_value="test_channel"),
|
||||
patch("semantic_kernel.agents.group_chat.agent_chat.AgentChat._synchronize_channel", return_value=mock_channel),
|
||||
patch.object(mock_channel, "get_history", return_value=AsyncMock()),
|
||||
):
|
||||
async for _ in agent_chat.get_chat_messages(agent):
|
||||
pass
|
||||
|
||||
|
||||
async def test_add_chat_message(agent_chat, chat_message):
|
||||
with patch(
|
||||
"semantic_kernel.agents.group_chat.agent_chat.AgentChat.add_chat_messages",
|
||||
return_value=AsyncMock(),
|
||||
) as mock_add_chat_messages:
|
||||
await agent_chat.add_chat_message(chat_message)
|
||||
mock_add_chat_messages.assert_called_once_with([chat_message])
|
||||
|
||||
|
||||
async def test_add_chat_messages(agent_chat, chat_message):
|
||||
with patch("semantic_kernel.agents.group_chat.broadcast_queue.BroadcastQueue.enqueue", return_value=AsyncMock()):
|
||||
await agent_chat.add_chat_messages([chat_message])
|
||||
assert chat_message in agent_chat.history.messages
|
||||
|
||||
|
||||
async def test_invoke_agent(agent_chat, agent, chat_message):
|
||||
mock_channel = mock.MagicMock(spec=AgentChannel)
|
||||
|
||||
async def mock_invoke(*args, **kwargs):
|
||||
yield True, chat_message
|
||||
|
||||
mock_channel.invoke.side_effect = mock_invoke
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.agents.group_chat.agent_chat.AgentChat._get_or_create_channel", return_value=mock_channel
|
||||
),
|
||||
patch(
|
||||
"semantic_kernel.agents.group_chat.broadcast_queue.BroadcastQueue.enqueue",
|
||||
return_value=AsyncMock(),
|
||||
),
|
||||
):
|
||||
async for _ in agent_chat.invoke_agent(agent):
|
||||
pass
|
||||
|
||||
mock_channel.invoke.assert_called_once_with(agent)
|
||||
await agent_chat.reset()
|
||||
|
||||
|
||||
async def test_invoke_streaming_agent(agent_chat, agent, chat_message):
|
||||
mock_channel = mock.MagicMock(spec=AgentChannel)
|
||||
|
||||
async def mock_invoke(*args, **kwargs):
|
||||
yield chat_message
|
||||
|
||||
mock_channel.invoke_stream.side_effect = mock_invoke
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.agents.group_chat.agent_chat.AgentChat._get_or_create_channel", return_value=mock_channel
|
||||
),
|
||||
patch(
|
||||
"semantic_kernel.agents.group_chat.broadcast_queue.BroadcastQueue.enqueue",
|
||||
return_value=AsyncMock(),
|
||||
),
|
||||
):
|
||||
async for _ in agent_chat.invoke_agent_stream(agent):
|
||||
pass
|
||||
|
||||
mock_channel.invoke_stream.assert_called_once_with(agent, [])
|
||||
await agent_chat.reset()
|
||||
|
||||
|
||||
async def test_synchronize_channel_with_existing_channel(agent_chat):
|
||||
mock_channel = MagicMock(spec=AgentChannel)
|
||||
channel_key = "test_channel_key"
|
||||
agent_chat.agent_channels[channel_key] = mock_channel
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.group_chat.broadcast_queue.BroadcastQueue.ensure_synchronized", return_value=AsyncMock()
|
||||
) as mock_ensure_synchronized:
|
||||
result = await agent_chat._synchronize_channel(channel_key)
|
||||
|
||||
assert result == mock_channel
|
||||
mock_ensure_synchronized.assert_called_once_with(ChannelReference(channel=mock_channel, hash=channel_key))
|
||||
|
||||
|
||||
async def test_synchronize_channel_with_nonexistent_channel(agent_chat):
|
||||
channel_key = "test_channel_key"
|
||||
|
||||
result = await agent_chat._synchronize_channel(channel_key)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_agent_hash_with_existing_hash(agent_chat, agent):
|
||||
expected_hash = "existing_hash"
|
||||
agent_chat.channel_map[agent] = expected_hash
|
||||
|
||||
result = agent_chat._get_agent_hash(agent)
|
||||
|
||||
assert result == expected_hash
|
||||
|
||||
|
||||
def test_get_agent_hash_generates_new_hash(agent_chat, agent):
|
||||
expected_hash = "new_hash"
|
||||
agent.get_channel_keys = MagicMock(return_value=["key1", "key2"])
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.group_chat.agent_chat.KeyEncoder.generate_hash", return_value=expected_hash
|
||||
) as mock_generate_hash:
|
||||
result = agent_chat._get_agent_hash(agent)
|
||||
|
||||
assert result == expected_hash
|
||||
mock_generate_hash.assert_called_once_with(["key1", "key2"])
|
||||
assert agent_chat.channel_map[agent] == expected_hash
|
||||
|
||||
|
||||
async def test_add_chat_messages_throws_exception_for_system_role(agent_chat):
|
||||
system_message = MagicMock(spec=ChatMessageContent)
|
||||
system_message.role = AuthorRole.SYSTEM
|
||||
|
||||
with pytest.raises(AgentChatException, match="System messages cannot be added to the chat history."):
|
||||
await agent_chat.add_chat_messages([system_message])
|
||||
|
||||
|
||||
async def test_get_or_create_channel_creates_new_channel(agent_chat, agent):
|
||||
agent_chat.history.messages = [MagicMock(spec=ChatMessageContent)]
|
||||
channel_key = "test_channel_key"
|
||||
mock_channel = AsyncMock(spec=AgentChannel)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.agents.group_chat.agent_chat.AgentChat._get_agent_hash", return_value=channel_key
|
||||
) as mock_get_agent_hash,
|
||||
patch(
|
||||
"semantic_kernel.agents.group_chat.agent_chat.AgentChat._synchronize_channel", return_value=None
|
||||
) as mock_synchronize_channel,
|
||||
):
|
||||
agent.create_channel = AsyncMock(return_value=mock_channel)
|
||||
with patch.object(mock_channel, "receive", return_value=AsyncMock()) as mock_receive:
|
||||
result = await agent_chat._get_or_create_channel(agent)
|
||||
|
||||
assert result == mock_channel
|
||||
mock_get_agent_hash.assert_called_once_with(agent)
|
||||
mock_synchronize_channel.assert_called_once_with(channel_key)
|
||||
agent.create_channel.assert_called_once()
|
||||
mock_receive.assert_called_once_with(agent_chat.history.messages)
|
||||
assert agent_chat.agent_channels[channel_key] == mock_channel
|
||||
|
||||
|
||||
async def test_get_or_create_channel_reuses_existing_channel(agent_chat, agent):
|
||||
channel_key = "test_channel_key"
|
||||
mock_channel = MagicMock(spec=AgentChannel)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.agents.group_chat.agent_chat.AgentChat._get_agent_hash", return_value=channel_key
|
||||
) as mock_get_agent_hash,
|
||||
patch(
|
||||
"semantic_kernel.agents.group_chat.agent_chat.AgentChat._synchronize_channel", return_value=mock_channel
|
||||
) as mock_synchronize_channel,
|
||||
):
|
||||
result = await agent_chat._get_or_create_channel(agent)
|
||||
|
||||
assert result == mock_channel
|
||||
mock_get_agent_hash.assert_called_once_with(agent)
|
||||
mock_synchronize_channel.assert_called_once_with(channel_key)
|
||||
agent.create_channel.assert_not_called()
|
||||
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import base64
|
||||
from hashlib import sha256
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.group_chat.agent_chat_utils import KeyEncoder
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentExecutionException
|
||||
|
||||
|
||||
def test_generate_hash_valid_keys():
|
||||
keys = ["key1", "key2", "key3"]
|
||||
expected_joined_keys = ":".join(keys).encode("utf-8")
|
||||
expected_hash = sha256(expected_joined_keys).digest()
|
||||
expected_base64 = base64.b64encode(expected_hash).decode("utf-8")
|
||||
|
||||
result = KeyEncoder.generate_hash(keys)
|
||||
|
||||
assert result == expected_base64
|
||||
|
||||
|
||||
def test_generate_hash_empty_keys():
|
||||
with pytest.raises(
|
||||
AgentExecutionException, match="Channel Keys must not be empty. Unable to generate channel hash."
|
||||
):
|
||||
KeyEncoder.generate_hash([])
|
||||
@@ -0,0 +1,341 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest import mock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.agent import Agent
|
||||
from semantic_kernel.agents.group_chat.agent_chat import AgentChat
|
||||
from semantic_kernel.agents.group_chat.agent_group_chat import AgentGroupChat
|
||||
from semantic_kernel.agents.strategies.selection.selection_strategy import SelectionStrategy
|
||||
from semantic_kernel.agents.strategies.selection.sequential_selection_strategy import SequentialSelectionStrategy
|
||||
from semantic_kernel.agents.strategies.termination.default_termination_strategy import DefaultTerminationStrategy
|
||||
from semantic_kernel.agents.strategies.termination.termination_strategy import TerminationStrategy
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentChatException
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agents():
|
||||
"""Fixture that provides a list of mock agents."""
|
||||
return [MagicMock(spec=Agent, id=f"agent-{i}") for i in range(3)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def termination_strategy():
|
||||
"""Fixture that provides a mock termination strategy."""
|
||||
return AsyncMock(spec=TerminationStrategy)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def selection_strategy():
|
||||
"""Fixture that provides a mock selection strategy."""
|
||||
return AsyncMock(spec=SelectionStrategy)
|
||||
|
||||
|
||||
# region Non-Streaming
|
||||
|
||||
|
||||
def test_agent_group_chat_initialization(agents, termination_strategy, selection_strategy):
|
||||
group_chat = AgentGroupChat(
|
||||
agents=agents, termination_strategy=termination_strategy, selection_strategy=selection_strategy
|
||||
)
|
||||
|
||||
assert group_chat.agents == agents
|
||||
assert group_chat.agent_ids == {agent.id for agent in agents}
|
||||
assert group_chat.termination_strategy == termination_strategy
|
||||
assert group_chat.selection_strategy == selection_strategy
|
||||
|
||||
|
||||
def test_agent_group_chat_initialization_defaults():
|
||||
group_chat = AgentGroupChat()
|
||||
|
||||
assert group_chat.agents == []
|
||||
assert group_chat.agent_ids == set()
|
||||
assert isinstance(group_chat.termination_strategy, DefaultTerminationStrategy)
|
||||
assert isinstance(group_chat.selection_strategy, SequentialSelectionStrategy)
|
||||
|
||||
|
||||
def test_add_agent(agents):
|
||||
group_chat = AgentGroupChat()
|
||||
|
||||
group_chat.add_agent(agents[0])
|
||||
|
||||
assert agents[0] in group_chat.agents
|
||||
assert agents[0].id in group_chat.agent_ids
|
||||
|
||||
|
||||
def test_add_duplicate_agent(agents):
|
||||
group_chat = AgentGroupChat(agents=[agents[0]])
|
||||
|
||||
group_chat.add_agent(agents[0])
|
||||
|
||||
assert len(group_chat.agents) == 1
|
||||
assert len(group_chat.agent_ids) == 1
|
||||
|
||||
|
||||
async def test_invoke_single_turn(agents, termination_strategy):
|
||||
group_chat = AgentGroupChat(termination_strategy=termination_strategy)
|
||||
|
||||
async def mock_invoke(agent, is_joining=True):
|
||||
yield MagicMock(role=AuthorRole.ASSISTANT)
|
||||
|
||||
with mock.patch.object(AgentGroupChat, "invoke", side_effect=mock_invoke):
|
||||
termination_strategy.should_terminate.return_value = False
|
||||
|
||||
async for message in group_chat.invoke_single_turn(agents[0]):
|
||||
assert message.role == AuthorRole.ASSISTANT
|
||||
|
||||
termination_strategy.should_terminate.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_invoke_single_turn_sets_complete(agents, termination_strategy):
|
||||
group_chat = AgentGroupChat(termination_strategy=termination_strategy)
|
||||
|
||||
async def mock_invoke(agent, is_joining=True):
|
||||
yield MagicMock(role=AuthorRole.ASSISTANT)
|
||||
|
||||
with mock.patch.object(AgentGroupChat, "invoke", side_effect=mock_invoke):
|
||||
termination_strategy.should_terminate.return_value = True
|
||||
|
||||
async for _ in group_chat.invoke_single_turn(agents[0]):
|
||||
pass
|
||||
|
||||
assert group_chat.is_complete is True
|
||||
termination_strategy.should_terminate.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_invoke_with_agent_joining(agents, termination_strategy):
|
||||
for agent in agents:
|
||||
agent.name = f"Agent {agent.id}"
|
||||
agent.id = f"agent-{agent.id}"
|
||||
|
||||
group_chat = AgentGroupChat(termination_strategy=termination_strategy)
|
||||
|
||||
with (
|
||||
mock.patch.object(AgentGroupChat, "add_agent", autospec=True) as mock_add_agent,
|
||||
mock.patch.object(AgentChat, "invoke_agent", autospec=True) as mock_invoke_agent,
|
||||
):
|
||||
|
||||
async def mock_invoke_gen(*args, **kwargs):
|
||||
yield MagicMock(role=AuthorRole.ASSISTANT)
|
||||
|
||||
mock_invoke_agent.side_effect = mock_invoke_gen
|
||||
|
||||
async for _ in group_chat.invoke(agents[0], is_joining=True):
|
||||
pass
|
||||
|
||||
mock_add_agent.assert_called_once_with(group_chat, agents[0])
|
||||
|
||||
|
||||
async def test_invoke_with_complete_chat(agents, termination_strategy):
|
||||
termination_strategy.automatic_reset = False
|
||||
group_chat = AgentGroupChat(agents=agents, termination_strategy=termination_strategy)
|
||||
group_chat.is_complete = True
|
||||
|
||||
with pytest.raises(AgentChatException, match="Chat is already complete"):
|
||||
async for _ in group_chat.invoke():
|
||||
pass
|
||||
|
||||
|
||||
async def test_invoke_agent_with_none_defined_errors(agents):
|
||||
group_chat = AgentGroupChat()
|
||||
|
||||
with pytest.raises(AgentChatException, match="No agents are available"):
|
||||
async for _ in group_chat.invoke():
|
||||
pass
|
||||
|
||||
|
||||
async def test_invoke_selection_strategy_error(agents, selection_strategy):
|
||||
group_chat = AgentGroupChat(agents=agents, selection_strategy=selection_strategy)
|
||||
|
||||
selection_strategy.next.side_effect = Exception("Selection failed")
|
||||
|
||||
with pytest.raises(AgentChatException, match="Failed to select agent"):
|
||||
async for _ in group_chat.invoke():
|
||||
pass
|
||||
|
||||
|
||||
async def test_invoke_iterations(agents, termination_strategy, selection_strategy):
|
||||
for agent in agents:
|
||||
agent.name = f"Agent {agent.id}"
|
||||
agent.id = f"agent-{agent.id}"
|
||||
|
||||
termination_strategy.maximum_iterations = 2
|
||||
|
||||
group_chat = AgentGroupChat(
|
||||
agents=agents, termination_strategy=termination_strategy, selection_strategy=selection_strategy
|
||||
)
|
||||
|
||||
selection_strategy.next.side_effect = lambda agents, history: agents[0]
|
||||
|
||||
async def mock_invoke_agent(*args, **kwargs):
|
||||
yield MagicMock(role=AuthorRole.ASSISTANT)
|
||||
|
||||
with mock.patch.object(AgentChat, "invoke_agent", side_effect=mock_invoke_agent):
|
||||
termination_strategy.should_terminate.return_value = False
|
||||
|
||||
iteration_count = 0
|
||||
async for _ in group_chat.invoke():
|
||||
iteration_count += 1
|
||||
|
||||
assert iteration_count == 2
|
||||
|
||||
|
||||
async def test_invoke_is_complete_then_reset(agents, termination_strategy, selection_strategy):
|
||||
for agent in agents:
|
||||
agent.name = f"Agent {agent.id}"
|
||||
agent.id = f"agent-{agent.id}"
|
||||
|
||||
termination_strategy.maximum_iterations = 2
|
||||
termination_strategy.automatic_reset = True
|
||||
|
||||
group_chat = AgentGroupChat(
|
||||
agents=agents, termination_strategy=termination_strategy, selection_strategy=selection_strategy
|
||||
)
|
||||
|
||||
group_chat.is_complete = True
|
||||
|
||||
selection_strategy.next.side_effect = lambda agents, history: agents[0]
|
||||
|
||||
async def mock_invoke_agent(*args, **kwargs):
|
||||
yield MagicMock(role=AuthorRole.ASSISTANT)
|
||||
|
||||
with mock.patch.object(AgentChat, "invoke_agent", side_effect=mock_invoke_agent):
|
||||
termination_strategy.should_terminate.return_value = False
|
||||
|
||||
iteration_count = 0
|
||||
async for _ in group_chat.invoke():
|
||||
iteration_count += 1
|
||||
|
||||
assert iteration_count == 2
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Streaming
|
||||
|
||||
|
||||
async def test_invoke_streaming_single_turn(agents, termination_strategy):
|
||||
group_chat = AgentGroupChat(termination_strategy=termination_strategy)
|
||||
|
||||
async def mock_invoke(agent, is_joining=True):
|
||||
yield MagicMock(role=AuthorRole.ASSISTANT)
|
||||
|
||||
with mock.patch.object(AgentGroupChat, "invoke_stream", side_effect=mock_invoke):
|
||||
termination_strategy.should_terminate.return_value = False
|
||||
|
||||
async for message in group_chat.invoke_stream_single_turn(agents[0]):
|
||||
assert message.role == AuthorRole.ASSISTANT
|
||||
|
||||
termination_strategy.should_terminate.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_invoke_stream_with_agent_joining(agents, termination_strategy):
|
||||
for agent in agents:
|
||||
agent.name = f"Agent {agent.id}"
|
||||
agent.id = f"agent-{agent.id}"
|
||||
|
||||
group_chat = AgentGroupChat(termination_strategy=termination_strategy)
|
||||
|
||||
with (
|
||||
mock.patch.object(AgentGroupChat, "add_agent", autospec=True) as mock_add_agent,
|
||||
mock.patch.object(AgentChat, "invoke_agent_stream", autospec=True) as mock_invoke_agent,
|
||||
):
|
||||
|
||||
async def mock_invoke_gen(*args, **kwargs):
|
||||
yield MagicMock(role=AuthorRole.ASSISTANT)
|
||||
|
||||
mock_invoke_agent.side_effect = mock_invoke_gen
|
||||
|
||||
async for _ in group_chat.invoke_stream(agents[0], is_joining=True):
|
||||
pass
|
||||
|
||||
mock_add_agent.assert_called_once_with(group_chat, agents[0])
|
||||
|
||||
|
||||
async def test_invoke_stream_with_complete_chat(agents, termination_strategy):
|
||||
termination_strategy.automatic_reset = False
|
||||
group_chat = AgentGroupChat(agents=agents, termination_strategy=termination_strategy)
|
||||
group_chat.is_complete = True
|
||||
|
||||
with pytest.raises(AgentChatException, match="Chat is already complete"):
|
||||
async for _ in group_chat.invoke_stream():
|
||||
pass
|
||||
|
||||
|
||||
async def test_invoke_stream_selection_strategy_error(agents, selection_strategy):
|
||||
group_chat = AgentGroupChat(agents=agents, selection_strategy=selection_strategy)
|
||||
|
||||
selection_strategy.next.side_effect = Exception("Selection failed")
|
||||
|
||||
with pytest.raises(AgentChatException, match="Failed to select agent"):
|
||||
async for _ in group_chat.invoke_stream():
|
||||
pass
|
||||
|
||||
|
||||
async def test_invoke_stream_iterations(agents, termination_strategy, selection_strategy):
|
||||
for agent in agents:
|
||||
agent.name = f"Agent {agent.id}"
|
||||
agent.id = f"agent-{agent.id}"
|
||||
|
||||
termination_strategy.maximum_iterations = 2
|
||||
|
||||
group_chat = AgentGroupChat(
|
||||
agents=agents, termination_strategy=termination_strategy, selection_strategy=selection_strategy
|
||||
)
|
||||
|
||||
selection_strategy.next.side_effect = lambda agents, history: agents[0]
|
||||
|
||||
async def mock_invoke_agent(*args, **kwargs):
|
||||
yield MagicMock(role=AuthorRole.ASSISTANT)
|
||||
|
||||
with mock.patch.object(AgentChat, "invoke_agent_stream", side_effect=mock_invoke_agent):
|
||||
termination_strategy.should_terminate.return_value = False
|
||||
|
||||
iteration_count = 0
|
||||
async for _ in group_chat.invoke_stream():
|
||||
iteration_count += 1
|
||||
|
||||
assert iteration_count == 2
|
||||
|
||||
|
||||
async def test_invoke_stream_is_complete_then_reset(agents, termination_strategy, selection_strategy):
|
||||
for agent in agents:
|
||||
agent.name = f"Agent {agent.id}"
|
||||
agent.id = f"agent-{agent.id}"
|
||||
|
||||
termination_strategy.maximum_iterations = 2
|
||||
termination_strategy.automatic_reset = True
|
||||
|
||||
group_chat = AgentGroupChat(
|
||||
agents=agents, termination_strategy=termination_strategy, selection_strategy=selection_strategy
|
||||
)
|
||||
|
||||
group_chat.is_complete = True
|
||||
|
||||
selection_strategy.next.side_effect = lambda agents, history: agents[0]
|
||||
|
||||
async def mock_invoke_agent(*args, **kwargs):
|
||||
yield MagicMock(role=AuthorRole.ASSISTANT)
|
||||
|
||||
with mock.patch.object(AgentChat, "invoke_agent_stream", side_effect=mock_invoke_agent):
|
||||
termination_strategy.should_terminate.return_value = False
|
||||
|
||||
iteration_count = 0
|
||||
async for _ in group_chat.invoke_stream():
|
||||
iteration_count += 1
|
||||
|
||||
assert iteration_count == 2
|
||||
|
||||
|
||||
async def test_invoke_streaming_agent_with_none_defined_errors(agents):
|
||||
group_chat = AgentGroupChat()
|
||||
|
||||
with pytest.raises(AgentChatException, match="No agents are available"):
|
||||
async for _ in group_chat.invoke_stream():
|
||||
pass
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,172 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.channels.agent_channel import AgentChannel
|
||||
from semantic_kernel.agents.group_chat.broadcast_queue import BroadcastQueue, ChannelReference, QueueReference
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def channel_ref():
|
||||
"""Fixture that provides a mock ChannelReference."""
|
||||
mock_channel = AsyncMock(spec=AgentChannel)
|
||||
return ChannelReference(channel=mock_channel, hash="test-hash")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def message():
|
||||
"""Fixture that provides a mock ChatMessageContent."""
|
||||
return MagicMock(spec=ChatMessageContent)
|
||||
|
||||
|
||||
# region QueueReference Tests
|
||||
|
||||
|
||||
def test_queue_reference_is_empty_true():
|
||||
queue_ref = QueueReference()
|
||||
assert queue_ref.is_empty is True
|
||||
|
||||
|
||||
def test_queue_reference_is_empty_false():
|
||||
queue_ref = QueueReference()
|
||||
queue_ref.queue.append(MagicMock())
|
||||
assert queue_ref.is_empty is False
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region BroadcastQueue Tests
|
||||
|
||||
|
||||
async def test_enqueue_new_channel(channel_ref, message):
|
||||
broadcast_queue = BroadcastQueue()
|
||||
|
||||
await broadcast_queue.enqueue([channel_ref], [message])
|
||||
|
||||
assert channel_ref.hash in broadcast_queue.queues
|
||||
queue_ref = broadcast_queue.queues[channel_ref.hash]
|
||||
assert queue_ref.queue[0] == [message]
|
||||
assert queue_ref.receive_task is not None
|
||||
assert not queue_ref.receive_task.done()
|
||||
|
||||
|
||||
async def test_enqueue_existing_channel(channel_ref, message):
|
||||
broadcast_queue = BroadcastQueue()
|
||||
|
||||
await broadcast_queue.enqueue([channel_ref], [message])
|
||||
|
||||
await broadcast_queue.enqueue([channel_ref], [message])
|
||||
|
||||
queue_ref = broadcast_queue.queues[channel_ref.hash]
|
||||
assert len(queue_ref.queue) == 2
|
||||
assert queue_ref.queue[1] == [message]
|
||||
assert queue_ref.receive_task is not None
|
||||
assert not queue_ref.receive_task.done()
|
||||
|
||||
|
||||
async def test_ensure_synchronized_channel_empty(channel_ref):
|
||||
broadcast_queue = BroadcastQueue()
|
||||
|
||||
await broadcast_queue.ensure_synchronized(channel_ref)
|
||||
assert channel_ref.hash not in broadcast_queue.queues
|
||||
|
||||
|
||||
async def test_ensure_synchronized_with_messages(channel_ref, message):
|
||||
broadcast_queue = BroadcastQueue()
|
||||
|
||||
await broadcast_queue.enqueue([channel_ref], [message])
|
||||
|
||||
await broadcast_queue.ensure_synchronized(channel_ref)
|
||||
|
||||
queue_ref = broadcast_queue.queues[channel_ref.hash]
|
||||
assert queue_ref.is_empty is True
|
||||
|
||||
|
||||
async def test_ensure_synchronized_with_failure(channel_ref, message):
|
||||
broadcast_queue = BroadcastQueue()
|
||||
|
||||
await broadcast_queue.enqueue([channel_ref], [message])
|
||||
|
||||
queue_ref = broadcast_queue.queues[channel_ref.hash]
|
||||
queue_ref.receive_failure = Exception("Simulated failure")
|
||||
|
||||
with pytest.raises(Exception, match="Unexpected failure broadcasting to channel"):
|
||||
await broadcast_queue.ensure_synchronized(channel_ref)
|
||||
|
||||
assert queue_ref.receive_failure is None
|
||||
|
||||
|
||||
async def test_ensure_synchronized_creates_new_task(channel_ref, message):
|
||||
broadcast_queue = BroadcastQueue()
|
||||
|
||||
await broadcast_queue.enqueue([channel_ref], [message])
|
||||
|
||||
queue_ref = broadcast_queue.queues[channel_ref.hash]
|
||||
|
||||
queue_ref.receive_task = None
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.group_chat.broadcast_queue.BroadcastQueue.receive", new_callable=AsyncMock
|
||||
) as mock_receive:
|
||||
mock_receive.return_value = await asyncio.sleep(0.1)
|
||||
|
||||
await broadcast_queue.ensure_synchronized(channel_ref)
|
||||
|
||||
assert queue_ref.receive_task is None
|
||||
|
||||
|
||||
async def test_receive_processes_queue(channel_ref, message):
|
||||
broadcast_queue = BroadcastQueue()
|
||||
|
||||
await broadcast_queue.enqueue([channel_ref], [message])
|
||||
|
||||
queue_ref = broadcast_queue.queues[channel_ref.hash]
|
||||
|
||||
await broadcast_queue.receive(channel_ref, queue_ref)
|
||||
|
||||
assert queue_ref.is_empty is True
|
||||
|
||||
assert channel_ref.channel.receive.await_count >= 1
|
||||
channel_ref.channel.receive.assert_any_await([message])
|
||||
|
||||
|
||||
async def test_receive_handles_failure(channel_ref, message):
|
||||
broadcast_queue = BroadcastQueue()
|
||||
|
||||
await broadcast_queue.enqueue([channel_ref], [message])
|
||||
|
||||
channel_ref.channel.receive.side_effect = Exception("Simulated failure")
|
||||
|
||||
queue_ref = broadcast_queue.queues[channel_ref.hash]
|
||||
|
||||
await broadcast_queue.receive(channel_ref, queue_ref)
|
||||
|
||||
assert queue_ref.receive_failure is not None
|
||||
assert str(queue_ref.receive_failure) == "Simulated failure"
|
||||
|
||||
|
||||
async def test_receive_breaks_when_queue_is_empty(channel_ref, message):
|
||||
broadcast_queue = BroadcastQueue()
|
||||
|
||||
await broadcast_queue.enqueue([channel_ref], [message])
|
||||
|
||||
queue_ref = broadcast_queue.queues[channel_ref.hash]
|
||||
|
||||
assert not queue_ref.is_empty
|
||||
|
||||
channel_ref.channel.receive = AsyncMock()
|
||||
|
||||
queue_ref.queue.clear()
|
||||
|
||||
await broadcast_queue.receive(channel_ref, queue_ref)
|
||||
|
||||
channel_ref.channel.receive.assert_not_awaited()
|
||||
|
||||
assert queue_ref.is_empty
|
||||
|
||||
|
||||
# endregion
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from semantic_kernel.agents.strategies.termination.aggregator_termination_strategy import (
|
||||
AggregateTerminationCondition,
|
||||
AggregatorTerminationStrategy,
|
||||
)
|
||||
from semantic_kernel.agents.strategies.termination.termination_strategy import TerminationStrategy
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from tests.unit.agents.test_agent import MockAgent
|
||||
|
||||
|
||||
async def test_aggregate_termination_condition_all_true():
|
||||
agent = MockAgent(id="test-agent-id")
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
|
||||
# Mocking two strategies that return True
|
||||
strategy1 = AsyncMock(spec=TerminationStrategy)
|
||||
strategy1.should_terminate.return_value = True
|
||||
|
||||
strategy2 = AsyncMock(spec=TerminationStrategy)
|
||||
strategy2.should_terminate.return_value = True
|
||||
|
||||
strategy = AggregatorTerminationStrategy(
|
||||
strategies=[strategy1, strategy2], condition=AggregateTerminationCondition.ALL
|
||||
)
|
||||
|
||||
result = await strategy.should_terminate_async(agent, history)
|
||||
|
||||
assert result is True
|
||||
strategy1.should_terminate.assert_awaited_once()
|
||||
strategy2.should_terminate.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_aggregate_termination_condition_all_false():
|
||||
agent = MockAgent(id="test-agent-id")
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
|
||||
# Mocking two strategies, one returns True, the other False
|
||||
strategy1 = AsyncMock(spec=TerminationStrategy)
|
||||
strategy1.should_terminate.return_value = True
|
||||
|
||||
strategy2 = AsyncMock(spec=TerminationStrategy)
|
||||
strategy2.should_terminate.return_value = False
|
||||
|
||||
strategy = AggregatorTerminationStrategy(
|
||||
strategies=[strategy1, strategy2], condition=AggregateTerminationCondition.ALL
|
||||
)
|
||||
|
||||
result = await strategy.should_terminate_async(agent, history)
|
||||
|
||||
assert result is False
|
||||
strategy1.should_terminate.assert_awaited_once()
|
||||
strategy2.should_terminate.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_aggregate_termination_condition_any_true():
|
||||
agent = MockAgent(id="test-agent-id")
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
|
||||
# Mocking two strategies, one returns False, the other True
|
||||
strategy1 = AsyncMock(spec=TerminationStrategy)
|
||||
strategy1.should_terminate.return_value = False
|
||||
|
||||
strategy2 = AsyncMock(spec=TerminationStrategy)
|
||||
strategy2.should_terminate.return_value = True
|
||||
|
||||
strategy = AggregatorTerminationStrategy(
|
||||
strategies=[strategy1, strategy2], condition=AggregateTerminationCondition.ANY
|
||||
)
|
||||
|
||||
result = await strategy.should_terminate_async(agent, history)
|
||||
|
||||
assert result is True
|
||||
strategy1.should_terminate.assert_awaited_once()
|
||||
strategy2.should_terminate.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_aggregate_termination_condition_any_false():
|
||||
agent = MockAgent(id="test-agent-id")
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
|
||||
# Mocking two strategies that return False
|
||||
strategy1 = AsyncMock(spec=TerminationStrategy)
|
||||
strategy1.should_terminate.return_value = False
|
||||
|
||||
strategy2 = AsyncMock(spec=TerminationStrategy)
|
||||
strategy2.should_terminate.return_value = False
|
||||
|
||||
strategy = AggregatorTerminationStrategy(
|
||||
strategies=[strategy1, strategy2], condition=AggregateTerminationCondition.ANY
|
||||
)
|
||||
|
||||
result = await strategy.should_terminate_async(agent, history)
|
||||
|
||||
assert result is False
|
||||
strategy1.should_terminate.assert_awaited_once()
|
||||
strategy2.should_terminate.assert_awaited_once()
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
from semantic_kernel.agents.strategies.termination.default_termination_strategy import DefaultTerminationStrategy
|
||||
|
||||
|
||||
async def test_should_agent_terminate_():
|
||||
strategy = DefaultTerminationStrategy(maximum_iterations=2)
|
||||
result = await strategy.should_agent_terminate(None, [])
|
||||
assert not result
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.strategies.selection.kernel_function_selection_strategy import (
|
||||
KernelFunctionSelectionStrategy,
|
||||
)
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentExecutionException
|
||||
from semantic_kernel.functions.kernel_function import KernelFunction
|
||||
from semantic_kernel.kernel import Kernel
|
||||
from tests.unit.agents.test_agent import MockAgent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agents():
|
||||
"""Fixture that provides a list of mock agents."""
|
||||
return [MockAgent(id=f"agent-{i}", name=f"Agent_{i}") for i in range(3)]
|
||||
|
||||
|
||||
async def test_kernel_function_selection_next_success(agents):
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
mock_function = AsyncMock(spec=KernelFunction)
|
||||
mock_function.invoke.return_value = MagicMock(value="Agent_1")
|
||||
mock_kernel = MagicMock(spec=Kernel)
|
||||
|
||||
strategy = KernelFunctionSelectionStrategy(
|
||||
function=mock_function, kernel=mock_kernel, result_parser=lambda result: result.value
|
||||
)
|
||||
|
||||
selected_agent = await strategy.next(agents, history)
|
||||
|
||||
assert selected_agent.name == "Agent_1"
|
||||
mock_function.invoke.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_kernel_function_selection_next_agent_not_found(agents):
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
mock_function = AsyncMock(spec=KernelFunction)
|
||||
mock_function.invoke.return_value = MagicMock(value="Nonexistent-Agent")
|
||||
mock_kernel = MagicMock(spec=Kernel)
|
||||
|
||||
strategy = KernelFunctionSelectionStrategy(
|
||||
function=mock_function, kernel=mock_kernel, result_parser=lambda result: result.value
|
||||
)
|
||||
|
||||
with pytest.raises(AgentExecutionException) as excinfo:
|
||||
await strategy.next(agents, history)
|
||||
|
||||
assert "Strategy unable to select next agent" in str(excinfo.value)
|
||||
mock_function.invoke.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_kernel_function_selection_next_result_is_none(agents):
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
mock_function = AsyncMock(spec=KernelFunction)
|
||||
mock_function.invoke.return_value = None
|
||||
mock_kernel = MagicMock(spec=Kernel)
|
||||
|
||||
strategy = KernelFunctionSelectionStrategy(
|
||||
function=mock_function, kernel=mock_kernel, result_parser=lambda result: result.value if result else None
|
||||
)
|
||||
|
||||
with pytest.raises(AgentExecutionException) as excinfo:
|
||||
await strategy.next(agents, history)
|
||||
|
||||
assert "Strategy unable to determine next agent" in str(excinfo.value)
|
||||
mock_function.invoke.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_kernel_function_selection_next_exception_during_invoke(agents):
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
mock_function = AsyncMock(spec=KernelFunction)
|
||||
mock_function.invoke.side_effect = Exception("Test exception")
|
||||
mock_kernel = MagicMock(spec=Kernel)
|
||||
|
||||
strategy = KernelFunctionSelectionStrategy(
|
||||
function=mock_function, kernel=mock_kernel, result_parser=lambda result: result.value
|
||||
)
|
||||
|
||||
with pytest.raises(AgentExecutionException) as excinfo:
|
||||
await strategy.next(agents, history)
|
||||
|
||||
assert "Strategy failed to execute function" in str(excinfo.value)
|
||||
mock_function.invoke.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_kernel_function_selection_result_parser_is_async(agents):
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
mock_function = AsyncMock(spec=KernelFunction)
|
||||
mock_function.invoke.return_value = MagicMock(value="Agent_2")
|
||||
mock_kernel = MagicMock(spec=Kernel)
|
||||
|
||||
async def async_result_parser(result):
|
||||
return result.value
|
||||
|
||||
strategy = KernelFunctionSelectionStrategy(
|
||||
function=mock_function, kernel=mock_kernel, result_parser=async_result_parser
|
||||
)
|
||||
|
||||
selected_agent = await strategy.next(agents, history)
|
||||
|
||||
assert selected_agent.name == "Agent_2"
|
||||
mock_function.invoke.assert_awaited_once()
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from semantic_kernel.agents.strategies import KernelFunctionTerminationStrategy
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.functions.kernel_function import KernelFunction
|
||||
from semantic_kernel.kernel import Kernel
|
||||
from tests.unit.agents.test_agent import MockAgent
|
||||
|
||||
|
||||
async def test_should_agent_terminate_with_result_true():
|
||||
agent = MockAgent(id="test-agent-id")
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
|
||||
mock_function = AsyncMock(spec=KernelFunction)
|
||||
mock_function.invoke.return_value = MagicMock(value=True)
|
||||
mock_kernel = MagicMock(spec=Kernel)
|
||||
|
||||
strategy = KernelFunctionTerminationStrategy(
|
||||
agents=[agent], function=mock_function, kernel=mock_kernel, result_parser=lambda result: result.value
|
||||
)
|
||||
|
||||
result = await strategy.should_agent_terminate(agent, history)
|
||||
|
||||
assert result is True
|
||||
mock_function.invoke.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_should_agent_terminate_with_result_false():
|
||||
agent = MockAgent(id="test-agent-id")
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
|
||||
mock_function = AsyncMock(spec=KernelFunction)
|
||||
mock_function.invoke.return_value = MagicMock(value=False)
|
||||
mock_kernel = MagicMock(spec=Kernel)
|
||||
|
||||
strategy = KernelFunctionTerminationStrategy(
|
||||
agents=[agent], function=mock_function, kernel=mock_kernel, result_parser=lambda result: result.value
|
||||
)
|
||||
|
||||
result = await strategy.should_agent_terminate(agent, history)
|
||||
|
||||
assert result is False
|
||||
mock_function.invoke.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_should_agent_terminate_with_none_result():
|
||||
agent = MockAgent(id="test-agent-id")
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
|
||||
mock_function = AsyncMock(spec=KernelFunction)
|
||||
mock_function.invoke.return_value = None
|
||||
mock_kernel = MagicMock(spec=Kernel)
|
||||
|
||||
strategy = KernelFunctionTerminationStrategy(
|
||||
agents=[agent],
|
||||
function=mock_function,
|
||||
kernel=mock_kernel,
|
||||
result_parser=lambda result: result.value if result else False,
|
||||
)
|
||||
|
||||
result = await strategy.should_agent_terminate(agent, history)
|
||||
|
||||
assert result is False
|
||||
mock_function.invoke.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_should_agent_terminate_custom_arguments():
|
||||
agent = MockAgent(id="test-agent-id")
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
|
||||
mock_function = AsyncMock(spec=KernelFunction)
|
||||
mock_function.invoke.return_value = MagicMock(value=True)
|
||||
mock_kernel = MagicMock(spec=Kernel)
|
||||
|
||||
custom_args = KernelArguments(execution_settings={"some_setting": MagicMock(model_dump=lambda: {"key": "value"})})
|
||||
|
||||
strategy = KernelFunctionTerminationStrategy(
|
||||
agents=[agent],
|
||||
function=mock_function,
|
||||
kernel=mock_kernel,
|
||||
arguments=custom_args,
|
||||
result_parser=lambda result: result.value,
|
||||
)
|
||||
|
||||
with patch.object(KernelArguments, "__init__", return_value=None) as mock_init:
|
||||
result = await strategy.should_agent_terminate(agent, history)
|
||||
mock_init.assert_called_once()
|
||||
|
||||
assert result is True
|
||||
mock_function.invoke.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_should_agent_terminate_result_parser_awaitable():
|
||||
agent = MockAgent(id="test-agent-id")
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
|
||||
mock_function = AsyncMock(spec=KernelFunction)
|
||||
mock_function.invoke.return_value = MagicMock(value=True)
|
||||
mock_kernel = MagicMock(spec=Kernel)
|
||||
|
||||
async def mock_result_parser(result):
|
||||
return result.value
|
||||
|
||||
strategy = KernelFunctionTerminationStrategy(
|
||||
agents=[agent], function=mock_function, kernel=mock_kernel, result_parser=mock_result_parser
|
||||
)
|
||||
|
||||
result = await strategy.should_agent_terminate(agent, history)
|
||||
|
||||
assert result is True
|
||||
mock_function.invoke.assert_awaited_once()
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents.agent import Agent
|
||||
from semantic_kernel.agents.strategies.selection.sequential_selection_strategy import SequentialSelectionStrategy
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentExecutionException
|
||||
from tests.unit.agents.test_agent import MockAgent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agents():
|
||||
"""Fixture that provides a list of mock agents."""
|
||||
return [MockAgent(id=f"agent-{i}") for i in range(3)]
|
||||
|
||||
|
||||
async def test_sequential_selection_next(agents):
|
||||
strategy = SequentialSelectionStrategy()
|
||||
|
||||
# Test the sequence of selections
|
||||
selected_agent_1 = await strategy.next(agents, [])
|
||||
selected_agent_2 = await strategy.next(agents, [])
|
||||
selected_agent_3 = await strategy.next(agents, [])
|
||||
|
||||
assert selected_agent_1.id == "agent-0"
|
||||
assert selected_agent_2.id == "agent-1"
|
||||
assert selected_agent_3.id == "agent-2"
|
||||
|
||||
|
||||
async def test_sequential_selection_wraps_around(agents):
|
||||
strategy = SequentialSelectionStrategy()
|
||||
|
||||
for _ in range(3):
|
||||
await strategy.next(agents, [])
|
||||
|
||||
selected_agent = await strategy.next(agents, [])
|
||||
assert selected_agent.id == "agent-0"
|
||||
|
||||
|
||||
async def test_sequential_selection_reset(agents):
|
||||
strategy = SequentialSelectionStrategy()
|
||||
|
||||
# Move the index to the middle of the list
|
||||
await strategy.next(agents, [])
|
||||
await strategy.next(agents, [])
|
||||
|
||||
strategy.reset()
|
||||
|
||||
selected_agent = await strategy.next(agents, [])
|
||||
assert selected_agent.id == "agent-0"
|
||||
|
||||
|
||||
async def test_sequential_selection_exceeds_length(agents):
|
||||
strategy = SequentialSelectionStrategy()
|
||||
|
||||
strategy._index = len(agents)
|
||||
|
||||
selected_agent = await strategy.next(agents, [])
|
||||
|
||||
assert selected_agent.id == "agent-0"
|
||||
assert strategy._index == 0
|
||||
|
||||
selected_agent = await strategy.next(agents, [])
|
||||
|
||||
assert selected_agent.id == "agent-1"
|
||||
assert strategy._index == 1
|
||||
|
||||
|
||||
async def test_sequential_selection_empty_agents():
|
||||
strategy = SequentialSelectionStrategy()
|
||||
|
||||
with pytest.raises(AgentExecutionException) as excinfo:
|
||||
await strategy.next([], [])
|
||||
|
||||
assert "Agent Failure - No agents present to select." in str(excinfo.value)
|
||||
|
||||
|
||||
async def test_sequential_selection_avoid_selecting_same_agent_twice():
|
||||
# Arrange
|
||||
agent_0 = MagicMock(spec=Agent)
|
||||
agent_0.id = "agent-0"
|
||||
agent_0.name = "Agent0"
|
||||
agent_0.plugins = []
|
||||
|
||||
agent_1 = MagicMock(spec=Agent)
|
||||
agent_1.id = "agent-1"
|
||||
agent_1.name = "Agent1"
|
||||
agent_1.plugins = []
|
||||
|
||||
agents = [agent_0, agent_1]
|
||||
|
||||
strategy = SequentialSelectionStrategy()
|
||||
# Simulate that we've already selected an agent once:
|
||||
strategy.has_selected = True
|
||||
# Set the initial agent to the first agent
|
||||
strategy.initial_agent = agent_0
|
||||
# Ensure the internal index is set to -1
|
||||
strategy._index = -1
|
||||
|
||||
# Act
|
||||
selected_agent = await strategy.next(agents, [])
|
||||
|
||||
# Assert
|
||||
# According to the condition, we should skip selecting agent_0 again
|
||||
assert selected_agent.id == "agent-1"
|
||||
assert strategy._index == 1
|
||||
@@ -0,0 +1,63 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from semantic_kernel.agents import Agent
|
||||
from semantic_kernel.agents.strategies.termination.termination_strategy import TerminationStrategy
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from tests.unit.agents.test_agent import MockAgent
|
||||
|
||||
|
||||
class TerminationStrategyTest(TerminationStrategy):
|
||||
"""A test implementation of TerminationStrategy for testing purposes."""
|
||||
|
||||
async def should_agent_terminate(self, agent: "Agent", history: list[ChatMessageContent]) -> bool:
|
||||
"""Simple test implementation that always returns True."""
|
||||
return True
|
||||
|
||||
|
||||
async def test_should_terminate_with_matching_agent():
|
||||
agent = MockAgent(id="test-agent-id")
|
||||
strategy = TerminationStrategyTest(agents=[agent])
|
||||
|
||||
# Assuming history is a list of ChatMessageContent; can be mocked or made minimal
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
|
||||
result = await strategy.should_terminate(agent, history)
|
||||
assert result is True
|
||||
|
||||
|
||||
async def test_should_terminate_with_non_matching_agent():
|
||||
agent = MockAgent(id="test-agent-id")
|
||||
non_matching_agent = MockAgent(id="non-matching-agent-id")
|
||||
strategy = TerminationStrategyTest(agents=[non_matching_agent])
|
||||
|
||||
# Assuming history is a list of ChatMessageContent; can be mocked or made minimal
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
|
||||
result = await strategy.should_terminate(agent, history)
|
||||
assert result is False
|
||||
|
||||
|
||||
async def test_should_terminate_no_agents_in_strategy():
|
||||
agent = MockAgent(id="test-agent-id")
|
||||
strategy = TerminationStrategyTest()
|
||||
|
||||
# Assuming history is a list of ChatMessageContent; can be mocked or made minimal
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
|
||||
result = await strategy.should_terminate(agent, history)
|
||||
assert result is True
|
||||
|
||||
|
||||
async def test_should_agent_terminate_not_implemented():
|
||||
agent = MockAgent(id="test-agent-id")
|
||||
strategy = TerminationStrategy(agents=[agent])
|
||||
|
||||
# Assuming history is a list of ChatMessageContent; can be mocked or made minimal
|
||||
history = [MagicMock(spec=ChatMessageContent)]
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
await strategy.should_agent_terminate(agent, history)
|
||||
@@ -0,0 +1,50 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions
|
||||
|
||||
|
||||
def test_get_polling_interval_below_threshold():
|
||||
options = RunPollingOptions()
|
||||
iteration_count = 1
|
||||
expected_interval = timedelta(milliseconds=250)
|
||||
assert options.get_polling_interval(iteration_count) == expected_interval
|
||||
|
||||
|
||||
def test_get_polling_interval_at_threshold():
|
||||
options = RunPollingOptions()
|
||||
iteration_count = 2
|
||||
expected_interval = timedelta(milliseconds=250)
|
||||
assert options.get_polling_interval(iteration_count) == expected_interval
|
||||
|
||||
|
||||
def test_get_polling_interval_above_threshold():
|
||||
options = RunPollingOptions()
|
||||
iteration_count = 3
|
||||
expected_interval = timedelta(seconds=1)
|
||||
assert options.get_polling_interval(iteration_count) == expected_interval
|
||||
|
||||
|
||||
def test_get_polling_interval_custom_threshold():
|
||||
options = RunPollingOptions(run_polling_backoff_threshold=5)
|
||||
iteration_count = 4
|
||||
expected_interval = timedelta(milliseconds=250)
|
||||
assert options.get_polling_interval(iteration_count) == expected_interval
|
||||
|
||||
iteration_count = 6
|
||||
expected_interval = timedelta(seconds=1)
|
||||
assert options.get_polling_interval(iteration_count) == expected_interval
|
||||
|
||||
|
||||
def test_get_polling_interval_custom_intervals():
|
||||
options = RunPollingOptions(
|
||||
run_polling_interval=timedelta(milliseconds=500), run_polling_backoff=timedelta(seconds=2)
|
||||
)
|
||||
iteration_count = 1
|
||||
expected_interval = timedelta(milliseconds=500)
|
||||
assert options.get_polling_interval(iteration_count) == expected_interval
|
||||
|
||||
iteration_count = 3
|
||||
expected_interval = timedelta(seconds=2)
|
||||
assert options.get_polling_interval(iteration_count) == expected_interval
|
||||
Reference in New Issue
Block a user