chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user