chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 13:21:23 +08:00
commit b957a53def
5423 changed files with 863745 additions and 0 deletions
@@ -0,0 +1,241 @@
# Copyright (c) Microsoft. All rights reserved.
import inspect
import logging
import sys
from collections.abc import Awaitable, Callable
from functools import wraps
from typing import Any
from semantic_kernel.agents.agent import Agent, AgentThread
from semantic_kernel.agents.orchestration.orchestration_base import DefaultTypeAlias
from semantic_kernel.agents.runtime.core.message_context import MessageContext
from semantic_kernel.agents.runtime.core.routed_agent import RoutedAgent
from semantic_kernel.contents import ChatHistory, ChatMessageContent, StreamingChatMessageContent
from semantic_kernel.utils.feature_stage_decorator import experimental
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
logger: logging.Logger = logging.getLogger(__name__)
@experimental
class ActorBase(RoutedAgent):
"""A base class for actors running in the AgentRuntime."""
def __init__(
self,
description: str,
exception_callback: Callable[[BaseException], None],
):
"""Initialize the actor with a description and an exception callback.
Args:
description (str): A description of the actor.
exception_callback (Callable[[BaseException], None]): A callback function to handle exceptions.
"""
super().__init__(description=description)
self._exception_callback = exception_callback
@override
async def on_message_impl(self, message: Any, ctx: MessageContext) -> Any | None:
"""Handle a message.
Stop the handling of the message if the cancellation token is cancelled.
"""
if ctx.cancellation_token.is_cancelled():
return None
return await super().on_message_impl(message, ctx)
@staticmethod
def exception_handler(func: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator that wraps a function in a try-catch block and calls the exception callback on errors.
This decorator can be used on both synchronous and asynchronous functions. When an exception
occurs during function execution, it will call the exception_callback with the exception
and then re-raise the exception.
Args:
func: The function to be wrapped.
Returns:
The wrapped function.
"""
log_message_template = "Exception occurred in agent {agent_id}:\n{exception}"
if inspect.iscoroutinefunction(func):
@wraps(func)
async def async_wrapper(self, *args, **kwargs):
try:
return await func(self, *args, **kwargs)
except BaseException as e:
self._exception_callback(e)
logger.error(log_message_template.format(agent_id=self.id, exception=e))
raise
return async_wrapper
@wraps(func)
def sync_wrapper(self, *args, **kwargs):
try:
return func(self, *args, **kwargs)
except BaseException as e:
self._exception_callback(e)
logger.error(log_message_template.format(agent_id=self.id, exception=e))
raise
return sync_wrapper
@experimental
class AgentActorBase(ActorBase):
"""A agent actor for multi-agent orchestration running on Agent runtime."""
def __init__(
self,
agent: Agent,
internal_topic_type: str,
exception_callback: Callable[[BaseException], None],
agent_response_callback: Callable[[DefaultTypeAlias], Awaitable[None] | None] | None = None,
streaming_agent_response_callback: Callable[[StreamingChatMessageContent, bool], Awaitable[None] | None]
| None = None,
) -> None:
"""Initialize the agent container.
Args:
agent (Agent): An agent to be run in the container.
internal_topic_type (str): The topic type of the internal topic.
exception_callback (Callable): A function that is called when an exception occurs.
agent_response_callback (Callable | None): A function that is called when a full response is produced
by the agents.
streaming_agent_response_callback (Callable | None): A function that is called when a streaming response
is produced by the agents.
"""
self._agent = agent
self._internal_topic_type = internal_topic_type
self._agent_response_callback = agent_response_callback
self._streaming_agent_response_callback = streaming_agent_response_callback
self._agent_thread: AgentThread | None = None
# Chat history to temporarily store messages before each invoke.
self._message_cache: ChatHistory = ChatHistory()
super().__init__(agent.description or "Semantic Kernel Actor", exception_callback)
async def _call_agent_response_callback(self, message: DefaultTypeAlias) -> None:
"""Call the agent_response_callback function if it is set.
Args:
message (DefaultTypeAlias): The message to be sent to the agent_response_callback.
"""
if self._agent_response_callback:
if inspect.iscoroutinefunction(self._agent_response_callback):
await self._agent_response_callback(message)
else:
self._agent_response_callback(message)
async def _call_streaming_agent_response_callback(
self,
message_chunk: StreamingChatMessageContent,
is_final: bool,
) -> None:
"""Call the streaming_agent_response_callback function if it is set.
Args:
message_chunk (StreamingChatMessageContent): The message chunk.
is_final (bool): Whether this is the final chunk of the response.
"""
if self._streaming_agent_response_callback:
if inspect.iscoroutinefunction(self._streaming_agent_response_callback):
await self._streaming_agent_response_callback(message_chunk, is_final)
else:
self._streaming_agent_response_callback(message_chunk, is_final)
@ActorBase.exception_handler
async def _invoke_agent(self, additional_messages: DefaultTypeAlias | None = None, **kwargs) -> ChatMessageContent:
"""Invoke the agent with the current chat history or thread and optionally additional messages.
Args:
additional_messages (DefaultTypeAlias | None): Additional messages to be sent to the agent.
**kwargs: Additional keyword arguments to be passed to the agent's invoke method:
- kernel: The kernel to use for the agent invocation.
Returns:
DefaultTypeAlias: The response from the agent.
"""
streaming_message_buffer: list[StreamingChatMessageContent] = []
messages = self._create_messages(additional_messages)
async for response_item in self._agent.invoke_stream(
messages, # type: ignore[arg-type]
thread=self._agent_thread,
on_intermediate_message=self._handle_intermediate_message,
**kwargs,
):
# Buffer message chunks and stream them with correct is_final flag.
streaming_message_buffer.append(response_item.message)
if len(streaming_message_buffer) > 1:
await self._call_streaming_agent_response_callback(streaming_message_buffer[-2], is_final=False)
if self._agent_thread is None:
self._agent_thread = response_item.thread
if streaming_message_buffer:
# Call the callback for the last message chunk with is_final=True.
await self._call_streaming_agent_response_callback(streaming_message_buffer[-1], is_final=True)
if not streaming_message_buffer:
raise RuntimeError(f'Agent "{self._agent.name}" did not return any response.')
# Build the full response from the streaming messages
full_response = sum(streaming_message_buffer[1:], streaming_message_buffer[0])
await self._call_agent_response_callback(full_response)
return full_response
def _create_messages(self, additional_messages: DefaultTypeAlias | None = None) -> list[ChatMessageContent]:
"""Create a list of messages to be sent to the agent along with a potential thread.
Args:
additional_messages (DefaultTypeAlias | None): Additional messages to be sent to the agent.
Returns:
list[ChatMessageContent]: A list of messages to be sent to the agent.
"""
base_messages = self._message_cache.messages[:]
# Clear the message cache for the next invoke.
self._message_cache.clear()
if additional_messages is None:
return base_messages
if isinstance(additional_messages, list):
return base_messages + additional_messages
return [*base_messages, additional_messages]
async def _handle_intermediate_message(self, message: ChatMessageContent) -> None:
"""Handle intermediate messages from the agent."""
await self._call_agent_response_callback(message)
if isinstance(message, StreamingChatMessageContent):
await self._call_streaming_agent_response_callback(message, is_final=True)
else:
# Convert to StreamingChatMessageContent if needed
streaming_message = StreamingChatMessageContent( # type: ignore[misc, call-overload]
role=message.role,
choice_index=0,
items=message.items,
content=message.content,
name=message.name,
inner_content=message.inner_content,
encoding=message.encoding,
finish_reason=message.finish_reason,
ai_model_id=message.ai_model_id,
metadata=message.metadata,
)
await self._call_streaming_agent_response_callback(streaming_message, is_final=True)
@@ -0,0 +1,247 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import sys
from collections.abc import Awaitable, Callable
from semantic_kernel.agents.agent import Agent
from semantic_kernel.agents.orchestration.agent_actor_base import ActorBase, AgentActorBase
from semantic_kernel.agents.orchestration.orchestration_base import (
ChatMessageContent,
DefaultTypeAlias,
OrchestrationBase,
TIn,
TOut,
)
from semantic_kernel.agents.runtime.core.cancellation_token import CancellationToken
from semantic_kernel.agents.runtime.core.core_runtime import CoreRuntime
from semantic_kernel.agents.runtime.core.message_context import MessageContext
from semantic_kernel.agents.runtime.core.routed_agent import message_handler
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.agents.runtime.in_process.type_subscription import TypeSubscription
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
logger: logging.Logger = logging.getLogger(__name__)
@experimental
class ConcurrentRequestMessage(KernelBaseModel):
"""A request message type for concurrent agents."""
body: DefaultTypeAlias
@experimental
class ConcurrentResponseMessage(KernelBaseModel):
"""A response message type for concurrent agents."""
body: ChatMessageContent
@experimental
class ConcurrentAgentActor(AgentActorBase):
"""A agent actor for concurrent agents that process tasks."""
def __init__(
self,
agent: Agent,
internal_topic_type: str,
collection_agent_type: str,
exception_callback: Callable[[BaseException], None],
agent_response_callback: Callable[[DefaultTypeAlias], Awaitable[None] | None] | None = None,
streaming_agent_response_callback: Callable[[StreamingChatMessageContent, bool], Awaitable[None] | None]
| None = None,
) -> None:
"""Initialize the agent actor.
Args:
agent: The agent to be executed.
internal_topic_type: The internal topic type for the actor.
collection_agent_type: The collection agent type for the actor.
exception_callback: A callback function to handle exceptions.
agent_response_callback: A callback function to handle the full response from the agent.
streaming_agent_response_callback: A callback function to handle streaming responses from the agent.
"""
self._collection_agent_type = collection_agent_type
super().__init__(
agent=agent,
internal_topic_type=internal_topic_type,
exception_callback=exception_callback,
agent_response_callback=agent_response_callback,
streaming_agent_response_callback=streaming_agent_response_callback,
)
@message_handler
async def _handle_message(self, message: ConcurrentRequestMessage, ctx: MessageContext) -> None:
"""Handle a message."""
logger.debug(f"Concurrent actor (Actor ID: {self.id}; Agent name: {self._agent.name}) started processing...")
response = await self._invoke_agent(additional_messages=message.body)
logger.debug(f"Concurrent actor (Actor ID: {self.id}; Agent name: {self._agent.name}) finished processing.")
target_actor_id = await self.runtime.get(self._collection_agent_type)
await self.send_message(
ConcurrentResponseMessage(body=response),
target_actor_id,
cancellation_token=ctx.cancellation_token,
)
@experimental
class CollectionActor(ActorBase):
"""A agent container for collecting results from concurrent agents."""
def __init__(
self,
description: str,
expected_answer_count: int,
exception_callback: Callable[[BaseException], None],
result_callback: Callable[[DefaultTypeAlias], Awaitable[None]] | None = None,
) -> None:
"""Initialize the collection agent container."""
self._expected_answer_count = expected_answer_count
self._result_callback = result_callback
self._results: list[ChatMessageContent] = []
self._lock = asyncio.Lock()
super().__init__(description, exception_callback)
@message_handler
async def _handle_message(self, message: ConcurrentResponseMessage, _: MessageContext) -> None:
async with self._lock:
self._results.append(message.body)
if len(self._results) == self._expected_answer_count:
logger.debug(f"Collection actor (Actor ID: {self.id}) finished processing all responses.")
if self._result_callback:
await self._result_callback(self._results)
@experimental
class ConcurrentOrchestration(OrchestrationBase[TIn, TOut]):
"""A concurrent multi-agent pattern orchestration."""
@override
async def _start(
self,
task: DefaultTypeAlias,
runtime: CoreRuntime,
internal_topic_type: str,
cancellation_token: CancellationToken,
) -> None:
"""Start the concurrent pattern."""
await runtime.publish_message(
ConcurrentRequestMessage(body=task),
TopicId(internal_topic_type, self.__class__.__name__),
cancellation_token=cancellation_token,
)
@override
async def _prepare(
self,
runtime: CoreRuntime,
internal_topic_type: str,
exception_callback: Callable[[BaseException], None],
result_callback: Callable[[DefaultTypeAlias], Awaitable[None]],
) -> None:
"""Register the actors and orchestrations with the runtime and add the required subscriptions."""
await asyncio.gather(*[
self._register_members(
runtime,
internal_topic_type,
exception_callback,
),
self._register_collection_actor(
runtime,
internal_topic_type,
exception_callback,
result_callback=result_callback,
),
self._add_subscriptions(
runtime,
internal_topic_type,
),
])
async def _register_members(
self,
runtime: CoreRuntime,
internal_topic_type: str,
exception_callback: Callable[[BaseException], None],
) -> None:
"""Register the members."""
async def _internal_helper(agent: Agent) -> None:
await ConcurrentAgentActor.register(
runtime,
self._get_agent_actor_type(agent, internal_topic_type),
lambda agent=agent: ConcurrentAgentActor( # type: ignore[misc]
agent,
internal_topic_type,
self._get_collection_actor_type(internal_topic_type),
exception_callback,
agent_response_callback=self._agent_response_callback,
streaming_agent_response_callback=self._streaming_agent_response_callback,
),
)
await asyncio.gather(*[_internal_helper(agent) for agent in self._members])
async def _register_collection_actor(
self,
runtime: CoreRuntime,
internal_topic_type: str,
exception_callback: Callable[[BaseException], None],
result_callback: Callable[[DefaultTypeAlias], Awaitable[None]] | None = None,
) -> None:
await CollectionActor.register(
runtime,
self._get_collection_actor_type(internal_topic_type),
lambda: CollectionActor(
description="An internal agent that is responsible for collection results",
expected_answer_count=len(self._members),
exception_callback=exception_callback,
result_callback=result_callback,
),
)
async def _add_subscriptions(
self,
runtime: CoreRuntime,
internal_topic_type: str,
) -> None:
await asyncio.gather(*[
runtime.add_subscription(
TypeSubscription(
internal_topic_type,
self._get_agent_actor_type(agent, internal_topic_type),
)
)
for agent in self._members
])
def _get_agent_actor_type(self, worker: Agent, internal_topic_type: str) -> str:
"""Get the container type for an agent.
The type is appended with the internal topic type to ensure uniqueness in the runtime
that may be shared by multiple orchestrations.
"""
return f"{worker.name}_{internal_topic_type}"
def _get_collection_actor_type(self, internal_topic_type: str) -> str:
"""Get the collection agent type.
The type is appended with the internal topic type to ensure uniqueness in the runtime
that may be shared by multiple orchestrations.
"""
return f"{CollectionActor.__name__}_{internal_topic_type}"
@@ -0,0 +1,530 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import inspect
import logging
import sys
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from typing import Generic, TypeVar
from semantic_kernel.agents.agent import Agent
from semantic_kernel.agents.orchestration.agent_actor_base import ActorBase, AgentActorBase
from semantic_kernel.agents.orchestration.orchestration_base import DefaultTypeAlias, OrchestrationBase, TIn, TOut
from semantic_kernel.agents.runtime.core.cancellation_token import CancellationToken
from semantic_kernel.agents.runtime.core.core_runtime import CoreRuntime
from semantic_kernel.agents.runtime.core.message_context import MessageContext
from semantic_kernel.agents.runtime.core.routed_agent import message_handler
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.agents.runtime.in_process.type_subscription import TypeSubscription
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 semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
logger: logging.Logger = logging.getLogger(__name__)
# region Messages and Types
@experimental
class GroupChatStartMessage(KernelBaseModel):
"""A message type to start a group chat."""
body: DefaultTypeAlias
@experimental
class GroupChatRequestMessage(KernelBaseModel):
"""A request message type for agents in a group chat."""
agent_name: str
@experimental
class GroupChatResponseMessage(KernelBaseModel):
"""A response message type from agents in a group chat."""
body: ChatMessageContent
_TGroupChatManagerResult = TypeVar("_TGroupChatManagerResult", ChatMessageContent, str, bool)
@experimental
class GroupChatManagerResult(KernelBaseModel, Generic[_TGroupChatManagerResult]):
"""A result message type from the group chat manager."""
result: _TGroupChatManagerResult
reason: str
# Subclassing GroupChatManagerResult to create specific result types because
# we need to change the names of the classes to remove the generic type parameters.
# Many model services (e.g. OpenAI) do not support generic type parameters in the
# class name (e.g. "GroupChatManagerResult[bool]").
@experimental
class BooleanResult(GroupChatManagerResult[bool]):
"""A result message type from the group chat manager with a boolean result."""
pass
@experimental
class StringResult(GroupChatManagerResult[str]):
"""A result message type from the group chat manager with a string result."""
pass
@experimental
class MessageResult(GroupChatManagerResult[ChatMessageContent]):
"""A result message type from the group chat manager with a message result."""
pass
# endregion Messages and Types
# region GroupChatAgentActor
@experimental
class GroupChatAgentActor(AgentActorBase):
"""An agent actor that process messages in a group chat."""
@message_handler
async def _handle_start_message(self, message: GroupChatStartMessage, ctx: MessageContext) -> None:
"""Handle the start message for the group chat."""
logger.debug(f"{self.id}: Received group chat start message.")
if isinstance(message.body, ChatMessageContent):
self._message_cache.add_message(message.body)
elif isinstance(message.body, list) and all(isinstance(m, ChatMessageContent) for m in message.body):
for m in message.body:
self._message_cache.add_message(m)
else:
raise ValueError(f"Invalid message body type: {type(message.body)}. Expected {DefaultTypeAlias}.")
@message_handler
async def _handle_response_message(self, message: GroupChatResponseMessage, ctx: MessageContext) -> None:
logger.debug(f"{self.id}: Received group chat response message.")
self._message_cache.add_message(message.body)
@message_handler
async def _handle_request_message(self, message: GroupChatRequestMessage, ctx: MessageContext) -> None:
if message.agent_name != self._agent.name:
return
logger.debug(f"{self.id}: Received group chat request message.")
response = await self._invoke_agent()
logger.debug(f"{self.id} responded with {response}.")
await self.publish_message(
GroupChatResponseMessage(body=response),
TopicId(self._internal_topic_type, self.id.key),
cancellation_token=ctx.cancellation_token,
)
# endregion GroupChatAgentActor
# region GroupChatManager
@experimental
class GroupChatManager(KernelBaseModel, ABC):
"""A group chat manager that manages the flow of a group chat."""
current_round: int = 0
max_rounds: int | None = None
human_response_function: Callable[[ChatHistory], Awaitable[ChatMessageContent] | ChatMessageContent] | None = None
@abstractmethod
async def should_request_user_input(self, chat_history: ChatHistory) -> BooleanResult:
"""Check if the group chat should request user input.
Args:
chat_history (ChatHistory): The chat history of the group chat.
"""
...
async def should_terminate(self, chat_history: ChatHistory) -> BooleanResult:
"""Check if the group chat should terminate.
Args:
chat_history (ChatHistory): The chat history of the group chat.
"""
self.current_round += 1
if self.max_rounds is not None:
return BooleanResult(
result=self.current_round > self.max_rounds,
reason="Maximum rounds reached."
if self.current_round > self.max_rounds
else "Not reached maximum rounds.",
)
return BooleanResult(result=False, reason="No maximum rounds set.")
@abstractmethod
async def select_next_agent(
self,
chat_history: ChatHistory,
participant_descriptions: dict[str, str],
) -> StringResult:
"""Select the next agent to speak.
Args:
chat_history (ChatHistory): The chat history of the group chat.
participant_descriptions (dict[str, str]): The descriptions of the participants in the group chat.
"""
...
@abstractmethod
async def filter_results(
self,
chat_history: ChatHistory,
) -> MessageResult:
"""Filter the results of the group chat.
Args:
chat_history (ChatHistory): The chat history of the group chat.
participant_descriptions (dict[str, str]): The descriptions of the participants in the group chat.
"""
...
@experimental
class RoundRobinGroupChatManager(GroupChatManager):
"""A round-robin group chat manager."""
current_index: int = 0
@override
async def should_request_user_input(self, chat_history: ChatHistory) -> BooleanResult:
"""Check if the group chat should request user input."""
return BooleanResult(
result=False,
reason="The default round-robin group chat manager does not request user input.",
)
@override
async def select_next_agent(
self,
chat_history: ChatHistory,
participant_descriptions: dict[str, str],
) -> StringResult:
"""Select the next agent to speak."""
next_agent = list(participant_descriptions.keys())[self.current_index]
self.current_index = (self.current_index + 1) % len(participant_descriptions)
return StringResult(result=next_agent, reason="Round-robin selection.")
@override
async def filter_results(
self,
chat_history: ChatHistory,
) -> MessageResult:
"""Filter the results of the group chat."""
return MessageResult(
result=chat_history.messages[-1],
reason="The last message in the chat history is the result in the default round-robin group chat manager.",
)
# endregion GroupChatManager
# region GroupChatManagerActor
@experimental
class GroupChatManagerActor(ActorBase):
"""A group chat manager actor."""
def __init__(
self,
manager: GroupChatManager,
internal_topic_type: str,
participant_descriptions: dict[str, str],
exception_callback: Callable[[BaseException], None],
result_callback: Callable[[DefaultTypeAlias], Awaitable[None]] | None = None,
):
"""Initialize the group chat manager actor.
Args:
manager (GroupChatManager): The group chat manager that manages the flow of the group chat.
internal_topic_type (str): The topic type of the internal topic.
participant_descriptions (dict[str, str]): The descriptions of the participants in the group chat.
exception_callback (Callable[[BaseException], None]): A function that is called when an exception occurs.
result_callback (Callable | None): A function that is called when the group chat manager produces a result.
"""
self._manager = manager
self._internal_topic_type = internal_topic_type
self._chat_history = ChatHistory()
self._participant_descriptions = participant_descriptions
self._result_callback = result_callback
super().__init__("An actor for the group chat manager.", exception_callback)
@message_handler
async def _handle_start_message(self, message: GroupChatStartMessage, ctx: MessageContext) -> None:
"""Handle the start message for the group chat."""
logger.debug(f"{self.id}: Received group chat start message.")
if isinstance(message.body, ChatMessageContent):
self._chat_history.add_message(message.body)
elif isinstance(message.body, list) and all(isinstance(m, ChatMessageContent) for m in message.body):
for m in message.body:
self._chat_history.add_message(m)
else:
raise ValueError(f"Invalid message body type: {type(message.body)}. Expected {DefaultTypeAlias}.")
await self._determine_state_and_take_action(ctx.cancellation_token)
@message_handler
async def _handle_response_message(self, message: GroupChatResponseMessage, ctx: MessageContext) -> None:
if message.body.role != AuthorRole.USER:
self._chat_history.add_message(
ChatMessageContent(
role=AuthorRole.USER,
content=f"Transferred to {message.body.name}",
)
)
self._chat_history.add_message(message.body)
await self._determine_state_and_take_action(ctx.cancellation_token)
@ActorBase.exception_handler
async def _determine_state_and_take_action(self, cancellation_token: CancellationToken) -> None:
"""Determine the state of the group chat and take action accordingly."""
# User input state
should_request_user_input = await self._manager.should_request_user_input(
self._chat_history.model_copy(deep=True)
)
if should_request_user_input.result and self._manager.human_response_function:
logger.debug(f"Group chat manager requested user input. Reason: {should_request_user_input.reason}")
user_input_message = await self._call_human_response_function()
self._chat_history.add_message(user_input_message)
await self.publish_message(
GroupChatResponseMessage(body=user_input_message),
TopicId(self._internal_topic_type, self.id.key),
cancellation_token=cancellation_token,
)
logger.debug("User input received and added to chat history.")
# Determine if the group chat should terminate
should_terminate = await self._manager.should_terminate(self._chat_history.model_copy(deep=True))
if should_terminate.result:
logger.debug(f"Group chat manager decided to terminate the group chat. Reason: {should_terminate.reason}")
if self._result_callback:
result = await self._manager.filter_results(self._chat_history.model_copy(deep=True))
result.result.metadata["termination_reason"] = should_terminate.reason
result.result.metadata["filter_result_reason"] = result.reason
await self._result_callback(result.result)
return
# Select the next agent to speak if the group chat is not terminating
next_agent = await self._manager.select_next_agent(
self._chat_history.model_copy(deep=True),
self._participant_descriptions,
)
logger.debug(
f"Group chat manager selected agent: {next_agent.result} on round {self._manager.current_round}. "
f"Reason: {next_agent.reason}"
)
await self.publish_message(
GroupChatRequestMessage(agent_name=next_agent.result),
TopicId(self._internal_topic_type, self.id.key),
cancellation_token=cancellation_token,
)
async def _call_human_response_function(self) -> ChatMessageContent:
"""Call the human response function if it is set."""
assert self._manager.human_response_function # nosec B101
if inspect.iscoroutinefunction(self._manager.human_response_function):
return await self._manager.human_response_function(self._chat_history.model_copy(deep=True))
return self._manager.human_response_function(self._chat_history.model_copy(deep=True)) # type: ignore[return-value]
# endregion GroupChatManagerActor
# region GroupChatOrchestration
@experimental
class GroupChatOrchestration(OrchestrationBase[TIn, TOut]):
"""A group chat multi-agent pattern orchestration."""
def __init__(
self,
members: list[Agent],
manager: GroupChatManager,
name: str | None = None,
description: str | None = None,
input_transform: Callable[[TIn], Awaitable[DefaultTypeAlias] | DefaultTypeAlias] | None = None,
output_transform: Callable[[DefaultTypeAlias], Awaitable[TOut] | TOut] | None = None,
agent_response_callback: Callable[[DefaultTypeAlias], Awaitable[None] | None] | None = None,
streaming_agent_response_callback: Callable[[StreamingChatMessageContent, bool], Awaitable[None] | None]
| None = None,
) -> None:
"""Initialize the group chat orchestration.
Args:
members (list[Agent | OrchestrationBase]): A list of agents or orchestrations that are part of the
handoff group. This first agent in the list will be the one that receives the first message.
manager (GroupChatManager): The group chat manager that manages the flow of the group chat.
name (str | None): The name of the orchestration.
description (str | None): The description of the orchestration.
input_transform (Callable | None): A function that transforms the external input message.
output_transform (Callable | None): A function that transforms the internal output message.
agent_response_callback (Callable | None): A function that is called when a full response is produced
by the agents.
streaming_agent_response_callback (Callable | None): A function that is called when a streaming response
is produced by the agents.
"""
self._manager = manager
for member in members:
if member.description is None:
raise ValueError("All members must have a description.")
super().__init__(
members=members,
name=name,
description=description,
input_transform=input_transform,
output_transform=output_transform,
agent_response_callback=agent_response_callback,
streaming_agent_response_callback=streaming_agent_response_callback,
)
@override
async def _start(
self,
task: DefaultTypeAlias,
runtime: CoreRuntime,
internal_topic_type: str,
cancellation_token: CancellationToken,
) -> None:
"""Start the group chat process.
This ensures that all initial messages are sent to the individual actors
and processed before the group chat begins. It's important because if the
manager actor processes its start message too quickly (or other actors are
too slow), it might send a request to the next agent before the other actors
have the necessary context.
"""
async def send_start_message(agent: Agent) -> None:
target_actor_id = await runtime.get(self._get_agent_actor_type(agent, internal_topic_type))
await runtime.send_message(
GroupChatStartMessage(body=task),
target_actor_id,
cancellation_token=cancellation_token,
)
await asyncio.gather(*[send_start_message(agent) for agent in self._members])
# Send the start message to the manager actor
target_actor_id = await runtime.get(self._get_manager_actor_type(internal_topic_type))
await runtime.send_message(
GroupChatStartMessage(body=task),
target_actor_id,
cancellation_token=cancellation_token,
)
@override
async def _prepare(
self,
runtime: CoreRuntime,
internal_topic_type: str,
exception_callback: Callable[[BaseException], None],
result_callback: Callable[[DefaultTypeAlias], Awaitable[None]],
) -> None:
"""Register the actors and orchestrations with the runtime and add the required subscriptions."""
await self._register_members(runtime, internal_topic_type, exception_callback)
await self._register_manager(runtime, internal_topic_type, exception_callback, result_callback)
await self._add_subscriptions(runtime, internal_topic_type)
async def _register_members(
self,
runtime: CoreRuntime,
internal_topic_type: str,
exception_callback: Callable[[BaseException], None],
) -> None:
"""Register the agents."""
await asyncio.gather(*[
GroupChatAgentActor.register(
runtime,
self._get_agent_actor_type(agent, internal_topic_type),
lambda agent=agent: GroupChatAgentActor( # type: ignore[misc]
agent,
internal_topic_type,
exception_callback=exception_callback,
agent_response_callback=self._agent_response_callback,
streaming_agent_response_callback=self._streaming_agent_response_callback,
),
)
for agent in self._members
])
async def _register_manager(
self,
runtime: CoreRuntime,
internal_topic_type: str,
exception_callback: Callable[[BaseException], None],
result_callback: Callable[[DefaultTypeAlias], Awaitable[None]] | None = None,
) -> None:
"""Register the group chat manager."""
await GroupChatManagerActor.register(
runtime,
self._get_manager_actor_type(internal_topic_type),
lambda: GroupChatManagerActor(
self._manager,
internal_topic_type=internal_topic_type,
participant_descriptions={agent.name: agent.description for agent in self._members}, # type: ignore[misc]
exception_callback=exception_callback,
result_callback=result_callback,
),
)
async def _add_subscriptions(self, runtime: CoreRuntime, internal_topic_type: str) -> None:
"""Add subscriptions."""
subscriptions: list[TypeSubscription] = []
for agent in self._members:
subscriptions.append(
TypeSubscription(internal_topic_type, self._get_agent_actor_type(agent, internal_topic_type))
)
subscriptions.append(TypeSubscription(internal_topic_type, self._get_manager_actor_type(internal_topic_type)))
await asyncio.gather(*[runtime.add_subscription(sub) for sub in subscriptions])
def _get_agent_actor_type(self, agent: Agent, internal_topic_type: str) -> str:
"""Get the actor type for an agent.
The type is appended with the internal topic type to ensure uniqueness in the runtime
that may be shared by multiple orchestrations.
"""
return f"{agent.name}_{internal_topic_type}"
def _get_manager_actor_type(self, internal_topic_type: str) -> str:
"""Get the actor type for the group chat manager.
The type is appended with the internal topic type to ensure uniqueness in the runtime
that may be shared by multiple orchestrations.
"""
return f"{GroupChatManagerActor.__name__}_{internal_topic_type}"
# endregion GroupChatOrchestration
@@ -0,0 +1,530 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import inspect
import logging
import sys
from collections.abc import Awaitable, Callable
from functools import partial
from semantic_kernel.agents.agent import Agent
from semantic_kernel.agents.orchestration.agent_actor_base import ActorBase, AgentActorBase
from semantic_kernel.agents.orchestration.orchestration_base import DefaultTypeAlias, OrchestrationBase, TIn, TOut
from semantic_kernel.agents.runtime.core.cancellation_token import CancellationToken
from semantic_kernel.agents.runtime.core.core_runtime import CoreRuntime
from semantic_kernel.agents.runtime.core.message_context import MessageContext
from semantic_kernel.agents.runtime.core.routed_agent import message_handler
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.agents.runtime.in_process.type_subscription import TypeSubscription
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.filters.auto_function_invocation.auto_function_invocation_context import (
AutoFunctionInvocationContext,
)
from semantic_kernel.filters.filter_types import FilterTypes
from semantic_kernel.functions.kernel_function_decorator import kernel_function
from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata
from semantic_kernel.functions.kernel_plugin import KernelPlugin
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
logger: logging.Logger = logging.getLogger(__name__)
# region Messages and Types
# A type alias for a mapping of agent names to their descriptions
# of the possible handoff connections for an agent.
AgentHandoffs = dict[str, str]
@experimental
class OrchestrationHandoffs(dict[str, AgentHandoffs]):
"""A dictionary mapping agent names to their handoff connections.
Handoff connections are represented as a dictionary where the key is the target agent name
and the value is a description of the handoff connection. For example:
{
"AgentA": {
"AgentB": "Transfer to Agent B for further assistance.",
"AgentC": "Transfer to Agent C for technical support."
},
"AgentB": {
"AgentA": "Transfer to Agent A for general inquiries.",
"AgentC": "Transfer to Agent C for billing issues."
}
}
This class allows for easy addition of handoff connections between agents.
"""
def add(self, source_agent: str | Agent, target_agent: str | Agent, description: str | None = None) -> "Self":
"""Add a handoff connection to the source agent.
Args:
source_agent (str | Agent): The source agent name or instance.
target_agent (str | Agent): The target agent name or instance.
description (str | None): The description of the handoff connection.
Returns:
Self: The updated orchestration handoffs, allowing for method chaining.
"""
return self._add(
source_agent=source_agent if isinstance(source_agent, str) else source_agent.name,
target_agent=target_agent if isinstance(target_agent, str) else target_agent.name,
description=description or target_agent.description or "" if isinstance(target_agent, Agent) else "",
)
def add_many(self, source_agent: str | Agent, target_agents: list[str | Agent] | AgentHandoffs) -> "Self":
"""Add multiple handoff connections to the source agent.
Args:
source_agent (str | Agent): The source agent name or instance.
target_agents (list[str | Agent] | AgentHandoffs): A list of target agent names or instances.
Returns:
Self: The updated orchestration handoffs, allowing for method chaining.
"""
if isinstance(target_agents, list):
for target_agent in target_agents:
self._add(
source_agent=source_agent if isinstance(source_agent, str) else source_agent.name,
target_agent=target_agent if isinstance(target_agent, str) else target_agent.name,
description=target_agent.description or "" if isinstance(target_agent, Agent) else "",
)
elif isinstance(target_agents, dict):
for target_agent_name, description in target_agents.items():
self._add(
source_agent=source_agent if isinstance(source_agent, str) else source_agent.name,
target_agent=target_agent_name,
description=description,
)
return self
def _add(self, source_agent: str, target_agent: str, description: str) -> "Self":
"""Helper method to add a handoff connection."""
self.setdefault(source_agent, AgentHandoffs())[target_agent] = description or ""
return self
@experimental
class HandoffStartMessage(KernelBaseModel):
"""A start message type to kick off a handoff group chat."""
body: DefaultTypeAlias
@experimental
class HandoffRequestMessage(KernelBaseModel):
"""A request message type for agents in a handoff group chat."""
agent_name: str
@experimental
class HandoffResponseMessage(KernelBaseModel):
"""A response message type from agents in a handoff group chat."""
body: ChatMessageContent
HANDOFF_PLUGIN_NAME = "Handoff"
# endregion Messages and Types
# region HandoffAgentActor
@experimental
class HandoffAgentActor(AgentActorBase):
"""An agent actor that handles handoff messages in a group chat."""
def __init__(
self,
agent: Agent,
internal_topic_type: str,
handoff_connections: AgentHandoffs,
exception_callback: Callable[[BaseException], None],
result_callback: Callable[[DefaultTypeAlias], Awaitable[None]] | None = None,
agent_response_callback: Callable[[DefaultTypeAlias], Awaitable[None] | None] | None = None,
streaming_agent_response_callback: Callable[[StreamingChatMessageContent, bool], Awaitable[None] | None]
| None = None,
human_response_function: Callable[[], Awaitable[ChatMessageContent] | ChatMessageContent] | None = None,
) -> None:
"""Initialize the handoff agent actor."""
self._handoff_connections = handoff_connections
self._result_callback = result_callback
self._kernel = agent.kernel.clone()
self._add_handoff_functions()
self._handoff_agent_name: str | None = None
self._task_completed = False
self._human_response_function = human_response_function
super().__init__(
agent=agent,
internal_topic_type=internal_topic_type,
exception_callback=exception_callback,
agent_response_callback=agent_response_callback,
streaming_agent_response_callback=streaming_agent_response_callback,
)
def _add_handoff_functions(self) -> None:
"""Add handoff functions to the agent's kernel."""
functions: list[KernelFunctionFromMethod] = []
for handoff_agent_name, handoff_description in self._handoff_connections.items():
function_name = f"transfer_to_{handoff_agent_name}"
function_description = handoff_description
return_parameter = KernelParameterMetadata(
name="return",
description="",
default_value=None,
type_="None",
type_object=None,
is_required=False,
)
function_metadata = KernelFunctionMetadata(
name=function_name,
description=function_description,
parameters=[],
return_parameter=return_parameter,
is_prompt=False,
is_asynchronous=True,
plugin_name=HANDOFF_PLUGIN_NAME,
additional_properties={},
)
functions.append(
KernelFunctionFromMethod.model_construct(
metadata=function_metadata,
method=partial(self._handoff_to_agent, handoff_agent_name),
)
)
functions.append(KernelFunctionFromMethod(self._complete_task, plugin_name=HANDOFF_PLUGIN_NAME))
self._kernel.add_plugin(plugin=KernelPlugin(name=HANDOFF_PLUGIN_NAME, functions=functions))
self._kernel.add_filter(FilterTypes.AUTO_FUNCTION_INVOCATION, self._handoff_function_filter)
async def _handoff_to_agent(self, agent_name: str) -> None:
"""Handoff the conversation to another agent."""
logger.debug(f"{self.id}: Setting handoff agent name to {agent_name}.")
self._handoff_agent_name = agent_name
async def _handoff_function_filter(self, context: AutoFunctionInvocationContext, next):
"""A filter to terminate an agent when it decides to handoff the conversation to another agent."""
await next(context)
if context.function.plugin_name == HANDOFF_PLUGIN_NAME:
context.terminate = True
@kernel_function(
name="complete_task", description="Complete the task with a summary when no further requests are given."
)
async def _complete_task(self, task_summary: str) -> None:
"""End the task with a summary."""
logger.debug(f"{self.id}: Completing task with summary: {task_summary}")
if self._result_callback:
await self._result_callback(
ChatMessageContent(
role=AuthorRole.ASSISTANT,
name=self._agent.name,
content=f"Task is completed with summary: {task_summary}",
)
)
self._task_completed = True
@message_handler
async def _handle_start_message(self, message: HandoffStartMessage, cts: MessageContext) -> None:
logger.debug(f"{self.id}: Received handoff start message.")
if isinstance(message.body, ChatMessageContent):
self._message_cache.add_message(message.body)
elif isinstance(message.body, list) and all(isinstance(m, ChatMessageContent) for m in message.body):
for m in message.body:
self._message_cache.add_message(m)
else:
raise ValueError(f"Invalid message body type: {type(message.body)}. Expected {DefaultTypeAlias}.")
@message_handler
async def _handle_response_message(self, message: HandoffResponseMessage, cts: MessageContext) -> None:
"""Handle a response message from an agent in the handoff group."""
logger.debug(f"{self.id}: Received handoff response message.")
self._message_cache.add_message(message.body)
@message_handler
async def _handle_request_message(self, message: HandoffRequestMessage, cts: MessageContext) -> None:
"""Handle a request message from an agent in the handoff group."""
if message.agent_name != self._agent.name:
return
logger.debug(f"{self.id}: Received handoff request message.")
response = await self._invoke_agent_with_potentially_no_response(kernel=self._kernel)
while not self._task_completed:
if self._handoff_agent_name:
await self.publish_message(
HandoffRequestMessage(agent_name=self._handoff_agent_name),
TopicId(self._internal_topic_type, self.id.key),
)
self._handoff_agent_name = None
break
if response is None:
raise RuntimeError(
f'Agent "{self._agent.name}" did not return any response nor did not set a handoff agent name.'
)
await self.publish_message(
HandoffResponseMessage(body=response),
TopicId(self._internal_topic_type, self.id.key),
cancellation_token=cts.cancellation_token,
)
if self._human_response_function:
human_response = await self._call_human_response_function()
await self.publish_message(
HandoffResponseMessage(body=human_response),
TopicId(self._internal_topic_type, self.id.key),
cancellation_token=cts.cancellation_token,
)
response = await self._invoke_agent_with_potentially_no_response(
additional_messages=human_response,
kernel=self._kernel,
)
else:
await self._complete_task(
task_summary="No handoff agent name provided and no human response function set. Ending task."
)
break
async def _call_human_response_function(self) -> ChatMessageContent:
"""Call the human response function if it is set."""
assert self._human_response_function # nosec B101
if inspect.iscoroutinefunction(self._human_response_function):
return await self._human_response_function()
return self._human_response_function() # type: ignore[return-value]
@ActorBase.exception_handler
async def _invoke_agent_with_potentially_no_response(
self, additional_messages: DefaultTypeAlias | None = None, **kwargs
) -> ChatMessageContent | None:
"""Invoke the agent with the current chat history or thread and optionally additional messages.
This method differs from `_invoke_agent` in that it handles the case where no response is returned
from the agent gracefully, returning `None` instead of raising an error.
The reason for this is that agents in a handoff group chat may not always produce a response when
a handoff function is invoked, where the `_handoff_function_filter` will terminate the auto function
invocation loop before a response is produced. In such cases, this method will return `None`
instead of raising an error.
"""
streaming_message_buffer: list[StreamingChatMessageContent] = []
messages = self._create_messages(additional_messages)
async for response_item in self._agent.invoke_stream(
messages, # type: ignore[arg-type]
thread=self._agent_thread,
on_intermediate_message=self._handle_intermediate_message,
**kwargs,
):
# Buffer message chunks and stream them with correct is_final flag.
streaming_message_buffer.append(response_item.message)
if len(streaming_message_buffer) > 1:
await self._call_streaming_agent_response_callback(streaming_message_buffer[-2], is_final=False)
if self._agent_thread is None:
self._agent_thread = response_item.thread
if streaming_message_buffer:
# Call the callback for the last message chunk with is_final=True.
await self._call_streaming_agent_response_callback(streaming_message_buffer[-1], is_final=True)
if not streaming_message_buffer:
return None
# Build the full response from the streaming messages
full_response = sum(streaming_message_buffer[1:], streaming_message_buffer[0])
await self._call_agent_response_callback(full_response)
return full_response
# endregion HandoffAgentActor
# region HandoffOrchestration
@experimental
class HandoffOrchestration(OrchestrationBase[TIn, TOut]):
"""An orchestration class for managing handoff agents in a group chat."""
def __init__(
self,
members: list[Agent],
handoffs: OrchestrationHandoffs,
name: str | None = None,
description: str | None = None,
input_transform: Callable[[TIn], Awaitable[DefaultTypeAlias] | DefaultTypeAlias] | None = None,
output_transform: Callable[[DefaultTypeAlias], Awaitable[TOut] | TOut] | None = None,
agent_response_callback: Callable[[DefaultTypeAlias], Awaitable[None] | None] | None = None,
streaming_agent_response_callback: Callable[[StreamingChatMessageContent, bool], Awaitable[None] | None]
| None = None,
human_response_function: Callable[[], Awaitable[ChatMessageContent] | ChatMessageContent] | None = None,
) -> None:
"""Initialize the handoff orchestration.
Args:
members (list[Agent]): A list of agents or orchestrations that are part of the
handoff group. This first agent in the list will be the one that receives the first message.
handoffs (OrchestrationHandoffs): Defines the handoff connections between agents.
name (str | None): The name of the orchestration.
description (str | None): The description of the orchestration.
input_transform (Callable | None): A function that transforms the external input message.
output_transform (Callable | None): A function that transforms the internal output message.
agent_response_callback (Callable | None): A function that is called when a full response is produced
by the agents.
streaming_agent_response_callback (Callable | None): A function that is called when a streaming response
is produced by the agents.
human_response_function (Callable | None): A function that is called when a human response is
needed.
"""
self._handoffs = handoffs
self._human_response_function = human_response_function
super().__init__(
members=members,
name=name,
description=description,
input_transform=input_transform,
output_transform=output_transform,
agent_response_callback=agent_response_callback,
streaming_agent_response_callback=streaming_agent_response_callback,
)
self._validate_handoffs()
@override
async def _start(
self,
task: DefaultTypeAlias,
runtime: CoreRuntime,
internal_topic_type: str,
cancellation_token: CancellationToken,
) -> None:
"""Start the handoff pattern.
This ensures that all initial messages are sent to the individual actors
and processed before the group chat begins. It's important because if the
manager actor processes its start message too quickly (or other actors are
too slow), it might send a request to the next agent before the other actors
have the necessary context.
"""
async def send_start_message(agent: Agent) -> None:
target_actor_id = await runtime.get(self._get_agent_actor_type(agent, internal_topic_type))
await runtime.send_message(
HandoffStartMessage(body=task),
target_actor_id,
cancellation_token=cancellation_token,
)
await asyncio.gather(*[send_start_message(agent) for agent in self._members])
# Send the handoff request message to the first agent in the list
target_actor_id = await runtime.get(self._get_agent_actor_type(self._members[0], internal_topic_type))
await runtime.send_message(
HandoffRequestMessage(agent_name=self._members[0].name),
target_actor_id,
cancellation_token=cancellation_token,
)
@override
async def _prepare(
self,
runtime: CoreRuntime,
internal_topic_type: str,
exception_callback: Callable[[BaseException], None],
result_callback: Callable[[DefaultTypeAlias], Awaitable[None]],
) -> None:
"""Register the actors and orchestrations with the runtime and add the required subscriptions."""
await self._register_members(runtime, internal_topic_type, exception_callback, result_callback)
await self._add_subscriptions(runtime, internal_topic_type)
async def _register_members(
self,
runtime: CoreRuntime,
internal_topic_type: str,
exception_callback: Callable[[BaseException], None],
result_callback: Callable[[DefaultTypeAlias], Awaitable[None]] | None = None,
) -> None:
"""Register the members with the runtime."""
async def _register_helper(agent: Agent) -> None:
handoff_connections = self._handoffs.get(agent.name, AgentHandoffs())
await HandoffAgentActor.register(
runtime,
self._get_agent_actor_type(agent, internal_topic_type),
lambda agent=agent, handoff_connections=handoff_connections: HandoffAgentActor( # type: ignore[misc]
agent,
internal_topic_type,
handoff_connections,
exception_callback,
result_callback=result_callback,
agent_response_callback=self._agent_response_callback,
streaming_agent_response_callback=self._streaming_agent_response_callback,
human_response_function=self._human_response_function,
),
)
await asyncio.gather(*[_register_helper(member) for member in self._members])
async def _add_subscriptions(self, runtime: CoreRuntime, internal_topic_type: str) -> None:
"""Add subscriptions to the runtime."""
subscriptions: list[TypeSubscription] = [
TypeSubscription(
internal_topic_type,
self._get_agent_actor_type(member, internal_topic_type),
)
for member in self._members
]
await asyncio.gather(*[runtime.add_subscription(subscription) for subscription in subscriptions])
def _get_agent_actor_type(self, agent: Agent, internal_topic_type: str) -> str:
"""Get the actor type for an agent.
The type is appended with the internal topic type to ensure uniqueness in the runtime
that may be shared by multiple orchestrations.
"""
return f"{agent.name}_{internal_topic_type}"
def _validate_handoffs(self) -> None:
"""Validate the handoffs to ensure all connections are valid."""
if not self._handoffs:
raise ValueError("Handoffs cannot be empty. Please provide at least one handoff connection.")
member_names = {m.name for m in self._members}
for agent_name, connections in self._handoffs.items():
if agent_name not in member_names:
raise ValueError(f"Agent {agent_name} is not a member of the handoff group.")
for handoff_agent_name in connections:
if handoff_agent_name not in member_names:
raise ValueError(f"Agent {handoff_agent_name} is not a member of the handoff group.")
if handoff_agent_name == agent_name:
raise ValueError(f"Agent {agent_name} cannot handoff to itself.")
# endregion HandoffOrchestration
@@ -0,0 +1,911 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import sys
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from html import escape
from typing import Annotated
from pydantic import Field
from semantic_kernel.agents.agent import Agent
from semantic_kernel.agents.orchestration.agent_actor_base import ActorBase, AgentActorBase
from semantic_kernel.agents.orchestration.orchestration_base import DefaultTypeAlias, OrchestrationBase, TIn, TOut
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.core.cancellation_token import CancellationToken
from semantic_kernel.agents.runtime.core.core_runtime import CoreRuntime
from semantic_kernel.agents.runtime.core.message_context import MessageContext
from semantic_kernel.agents.runtime.core.routed_agent import message_handler
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.agents.runtime.in_process.type_subscription import TypeSubscription
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 semantic_kernel.functions.kernel_arguments import KernelArguments
from semantic_kernel.kernel import Kernel
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.prompt_template.kernel_prompt_template import KernelPromptTemplate
from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig
from semantic_kernel.utils.feature_stage_decorator import experimental
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
logger: logging.Logger = logging.getLogger(__name__)
# region Messages and Types
@experimental
class MagenticStartMessage(KernelBaseModel):
"""A message to start a magentic group chat."""
body: ChatMessageContent
@experimental
class MagenticRequestMessage(KernelBaseModel):
"""A request message type for agents in a magentic group chat."""
agent_name: str
@experimental
class MagenticResponseMessage(KernelBaseModel):
"""A response message type from agents in a magentic group chat."""
body: ChatMessageContent
@experimental
class MagenticResetMessage(KernelBaseModel):
"""A message to reset a participant's chat history in a magentic group chat."""
pass
@experimental
class ProgressLedgerItem(KernelBaseModel):
"""A progress ledger item."""
reason: str
answer: str | bool
@experimental
class ProgressLedger(KernelBaseModel):
"""A progress ledger."""
is_request_satisfied: ProgressLedgerItem
is_in_loop: ProgressLedgerItem
is_progress_being_made: ProgressLedgerItem
next_speaker: ProgressLedgerItem
instruction_or_question: ProgressLedgerItem
@experimental
class MagenticContext(KernelBaseModel):
"""Context for the Magentic manager."""
task: Annotated[ChatMessageContent, Field(description="The task to be completed.")]
chat_history: Annotated[
ChatHistory, Field(description="The chat history to be used to generate the facts and plan.")
] = ChatHistory()
participant_descriptions: Annotated[
dict[str, str], Field(description="The descriptions of the participants in the group.")
]
round_count: Annotated[int, Field(description="The number of rounds completed.")] = 0
stall_count: Annotated[int, Field(description="The number of stalls detected.")] = 0
reset_count: Annotated[int, Field(description="The number of resets detected.")] = 0
def reset(self) -> None:
"""Reset the context.
This will clear the chat history and reset the stall count.
This won't reset the task, round count, or participant descriptions.
"""
self.chat_history.clear()
self.stall_count = 0
self.reset_count += 1
# endregion Messages and Types
# region MagenticManager
@experimental
class MagenticManagerBase(KernelBaseModel, ABC):
"""Base class for the Magentic One manager."""
max_stall_count: Annotated[int, Field(description="The maximum number of stalls allowed before a reset.", ge=0)] = 3
max_reset_count: Annotated[int | None, Field(description="The maximum number of resets allowed.", ge=0)] = None
max_round_count: Annotated[
int | None, Field(description="The maximum number of rounds (agent responses) allowed.", gt=0)
] = None
@abstractmethod
async def plan(self, magentic_context: MagenticContext) -> ChatMessageContent:
"""Create a plan for the task.
This is called when the task is first started.
Args:
magentic_context (MagenticContext): The context for the Magentic manager.
Returns:
ChatMessageContent: The task ledger.
"""
...
@abstractmethod
async def replan(self, magentic_context: MagenticContext) -> ChatMessageContent:
"""Replan for the task.
This is called when the task is stalled or looping.
Args:
magentic_context (MagenticContext): The context for the Magentic manager.
Returns:
ChatMessageContent: The updated task ledger.
"""
...
@abstractmethod
async def create_progress_ledger(self, magentic_context: MagenticContext) -> ProgressLedger:
"""Create a progress ledger.
Args:
magentic_context (MagenticContext): The context for the Magentic manager.
Returns:
ProgressLedger: The progress ledger.
"""
...
@abstractmethod
async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessageContent:
"""Prepare the final answer.
Args:
magentic_context (MagenticContext): The context for the Magentic manager.
Returns:
ChatMessageContent: The final answer.
"""
...
@experimental
class _TaskLedger(KernelBaseModel):
"""Task ledger for the Standard Magentic manager."""
facts: Annotated[ChatMessageContent, Field(description="The facts about the task.")]
plan: Annotated[ChatMessageContent, Field(description="The plan for the task.")]
@experimental
class StandardMagenticManager(MagenticManagerBase):
"""Standard Magentic manager implementation.
This is the default implementation of the Magentic manager.
It uses the task ledger to keep track of the facts and plan for the task.
This implementation requires a chat completion model with structured outputs.
"""
chat_completion_service: ChatCompletionClientBase
prompt_execution_settings: PromptExecutionSettings
task_ledger_facts_prompt: str = ORCHESTRATOR_TASK_LEDGER_FACTS_PROMPT
task_ledger_plan_prompt: str = ORCHESTRATOR_TASK_LEDGER_PLAN_PROMPT
task_ledger_full_prompt: str = ORCHESTRATOR_TASK_LEDGER_FULL_PROMPT
task_ledger_facts_update_prompt: str = ORCHESTRATOR_TASK_LEDGER_FACTS_UPDATE_PROMPT
task_ledger_plan_update_prompt: str = ORCHESTRATOR_TASK_LEDGER_PLAN_UPDATE_PROMPT
progress_ledger_prompt: str = ORCHESTRATOR_PROGRESS_LEDGER_PROMPT
final_answer_prompt: str = ORCHESTRATOR_FINAL_ANSWER_PROMPT
task_ledger: _TaskLedger | None = None
def __init__(
self,
chat_completion_service: ChatCompletionClientBase,
prompt_execution_settings: PromptExecutionSettings | None = None,
**kwargs,
) -> None:
"""Initialize the Standard Magentic manager.
Args:
chat_completion_service (ChatCompletionClientBase): The chat completion service to use.
prompt_execution_settings (PromptExecutionSettings | None): The prompt execution settings to use.
**kwargs: Additional keyword arguments for prompts:
- task_ledger_facts_prompt: The prompt to use for the task ledger facts.
- task_ledger_plan_prompt: The prompt to use for the task ledger plan.
- task_ledger_full_prompt: The prompt to use for the full task ledger.
- task_ledger_facts_update_prompt: The prompt to use for the task ledger facts update.
- task_ledger_plan_update_prompt: The prompt to use for the task ledger plan update.
- progress_ledger_prompt: The prompt to use for the progress ledger.
- final_answer_prompt: The prompt to use for the final answer.
"""
# Bast effort to make sure the service supports structured output. Even if the service supports
# structured output, the model may not support it, in which case there is no good way to check.
if prompt_execution_settings is None:
prompt_execution_settings = chat_completion_service.instantiate_prompt_execution_settings()
if not hasattr(prompt_execution_settings, "response_format"):
raise ValueError("The service must support structured output.")
else:
if not hasattr(prompt_execution_settings, "response_format"):
raise ValueError("The service must support structured output.")
if getattr(prompt_execution_settings, "response_format", None) is not None:
raise ValueError("The prompt execution settings must not have a response format set.")
super().__init__(
chat_completion_service=chat_completion_service,
prompt_execution_settings=prompt_execution_settings,
**kwargs,
)
@override
async def plan(self, magentic_context: MagenticContext) -> ChatMessageContent:
"""Plan the task.
Args:
magentic_context (MagenticContext): The context for the Magentic manager.
Returns:
ChatMessageContent: The task ledger.
"""
# 1. Gather the facts
prompt_template = KernelPromptTemplate(
prompt_template_config=PromptTemplateConfig(template=self.task_ledger_facts_prompt)
)
magentic_context.chat_history.add_message(
ChatMessageContent(
role=AuthorRole.USER,
content=await prompt_template.render(Kernel(), KernelArguments(task=magentic_context.task.content)),
)
)
facts = await self.chat_completion_service.get_chat_message_content(
magentic_context.chat_history,
self.prompt_execution_settings,
)
assert facts is not None # nosec B101
magentic_context.chat_history.add_message(facts)
# 2. Create the plan
prompt_template = KernelPromptTemplate(
prompt_template_config=PromptTemplateConfig(template=self.task_ledger_plan_prompt),
allow_dangerously_set_content=True,
)
escaped_participant_descriptions: dict[str, str] = {}
for key, value in magentic_context.participant_descriptions.items():
escaped_participant_descriptions[key] = escape(value)
magentic_context.chat_history.add_message(
ChatMessageContent(
role=AuthorRole.USER,
content=await prompt_template.render(
Kernel(),
KernelArguments(team=escaped_participant_descriptions),
),
)
)
plan = await self.chat_completion_service.get_chat_message_content(
magentic_context.chat_history,
self.prompt_execution_settings,
)
assert plan is not None # nosec B101
self.task_ledger = _TaskLedger(facts=facts, plan=plan)
return await self._render_task_ledger(magentic_context)
@override
async def replan(self, magentic_context: MagenticContext) -> ChatMessageContent:
"""Replan the task.
Args:
magentic_context (MagenticContext): The context for the Magentic manager.
Returns:
ChatMessageContent: The updated task ledger.
"""
if self.task_ledger is None:
raise RuntimeError("The task ledger is not initialized. Planning needs to happen first.")
# 1. Update the facts
prompt_template = KernelPromptTemplate(
prompt_template_config=PromptTemplateConfig(template=self.task_ledger_facts_update_prompt)
)
magentic_context.chat_history.add_message(
ChatMessageContent(
role=AuthorRole.USER,
content=await prompt_template.render(
Kernel(),
KernelArguments(task=magentic_context.task.content, old_facts=self.task_ledger.facts.content),
),
)
)
facts = await self.chat_completion_service.get_chat_message_content(
magentic_context.chat_history,
self.prompt_execution_settings,
)
assert facts is not None # nosec B101
magentic_context.chat_history.add_message(facts)
# 2. Update the plan
prompt_template = KernelPromptTemplate(
prompt_template_config=PromptTemplateConfig(template=self.task_ledger_plan_update_prompt),
allow_dangerously_set_content=True,
)
escaped_participant_descriptions: dict[str, str] = {}
for key, value in magentic_context.participant_descriptions.items():
escaped_participant_descriptions[key] = escape(value)
magentic_context.chat_history.add_message(
ChatMessageContent(
role=AuthorRole.USER,
content=await prompt_template.render(
Kernel(),
KernelArguments(team=escaped_participant_descriptions),
),
)
)
plan = await self.chat_completion_service.get_chat_message_content(
magentic_context.chat_history,
self.prompt_execution_settings,
)
assert plan is not None # nosec B101
self.task_ledger.facts = facts
self.task_ledger.plan = plan
return await self._render_task_ledger(magentic_context)
async def _render_task_ledger(self, magentic_context: MagenticContext) -> ChatMessageContent:
"""Render the task ledger to a string.
Args:
magentic_context (MagenticContext): The context for the Magentic manager.
Returns:
ChatMessageContent: The rendered task ledger.
"""
if self.task_ledger is None:
raise RuntimeError("The task ledger is not initialized. Planning needs to happen first.")
prompt_template = KernelPromptTemplate(
prompt_template_config=PromptTemplateConfig(template=self.task_ledger_full_prompt),
allow_dangerously_set_content=True,
)
escaped_participant_descriptions: dict[str, str] = {}
for key, value in magentic_context.participant_descriptions.items():
escaped_participant_descriptions[key] = escape(value)
rendered_task_ledger = await prompt_template.render(
Kernel(),
KernelArguments(
task=magentic_context.task.content,
team=escaped_participant_descriptions,
facts=self.task_ledger.facts.content,
plan=self.task_ledger.plan.content,
),
)
return ChatMessageContent(role=AuthorRole.ASSISTANT, content=rendered_task_ledger)
@override
async def create_progress_ledger(self, magentic_context: MagenticContext) -> ProgressLedger:
"""Create a progress ledger.
Args:
magentic_context (MagenticContext): The context for the Magentic manager.
Returns:
ProgressLedger: The progress ledger.
"""
prompt_template = KernelPromptTemplate(
prompt_template_config=PromptTemplateConfig(template=self.progress_ledger_prompt),
allow_dangerously_set_content=True,
)
escaped_participant_descriptions: dict[str, str] = {}
for key, value in magentic_context.participant_descriptions.items():
escaped_participant_descriptions[key] = escape(value)
progress_ledger_prompt = await prompt_template.render(
Kernel(),
KernelArguments(
task=magentic_context.task.content,
team=escaped_participant_descriptions,
names=", ".join(magentic_context.participant_descriptions.keys()),
),
)
magentic_context.chat_history.add_message(
ChatMessageContent(role=AuthorRole.USER, content=progress_ledger_prompt)
)
prompt_execution_settings_clone = PromptExecutionSettings.from_prompt_execution_settings(
self.prompt_execution_settings
)
prompt_execution_settings_clone.update_from_prompt_execution_settings(
PromptExecutionSettings(extension_data={"response_format": ProgressLedger})
)
response = await self.chat_completion_service.get_chat_message_content(
magentic_context.chat_history,
prompt_execution_settings_clone,
)
assert response is not None # nosec B101
return ProgressLedger.model_validate_json(response.content)
@override
async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessageContent:
"""Prepare the final answer.
Args:
magentic_context (MagenticContext): The context for the Magentic manager.
Returns:
ChatMessageContent: The final answer.
"""
prompt_template = KernelPromptTemplate(
prompt_template_config=PromptTemplateConfig(template=self.final_answer_prompt),
allow_dangerously_set_content=True,
)
magentic_context.task.content = escape(magentic_context.task.content)
magentic_context.chat_history.add_message(
ChatMessageContent(
role=AuthorRole.USER,
content=await prompt_template.render(Kernel(), KernelArguments(task=magentic_context.task)),
)
)
response = await self.chat_completion_service.get_chat_message_content(
magentic_context.chat_history,
self.prompt_execution_settings,
)
assert response is not None # nosec B101
return response
# endregion MagenticManager
# region MagenticManagerActor
@experimental
class MagenticManagerActor(ActorBase):
"""Actor for the Magentic One manager."""
def __init__(
self,
manager: MagenticManagerBase,
internal_topic_type: str,
participant_descriptions: dict[str, str],
exception_callback: Callable[[BaseException], None],
result_callback: Callable[[DefaultTypeAlias], Awaitable[None]] | None = None,
) -> None:
"""Initialize the Magentic One manager actor.
Args:
manager (MagenticManagerBase): The Magentic One manager.
internal_topic_type (str): The internal topic type.
participant_descriptions (dict[str, str]): The participant descriptions.
exception_callback (Callable[[BaseException], None]): A callback function to handle exceptions.
result_callback (Callable | None): A callback function to handle the final answer.
"""
self._manager = manager
self._internal_topic_type = internal_topic_type
self._result_callback = result_callback
self._participant_descriptions = participant_descriptions
self._context: MagenticContext | None = None
self._task_ledger: ChatMessageContent | None = None
super().__init__("Magentic One Manager", exception_callback)
@message_handler
@ActorBase.exception_handler
async def _handle_start_message(self, message: MagenticStartMessage, ctx: MessageContext) -> None:
"""Handle the start message for the Magentic One manager."""
logger.debug(f"{self.id}: Received Magentic One start message.")
self._context = MagenticContext(
task=message.body,
participant_descriptions=self._participant_descriptions,
)
# Initial planning
self._task_ledger = await self._manager.plan(self._context.model_copy(deep=True))
await self._run_outer_loop(ctx.cancellation_token)
@message_handler
@ActorBase.exception_handler
async def _handle_response_message(self, message: MagenticResponseMessage, ctx: MessageContext) -> None:
"""Handle the response message for the Magentic One manager."""
if self._context is None or self._task_ledger is None:
raise RuntimeError("The Magentic manager is not started yet. Make sure to send a start message first.")
if message.body.role != AuthorRole.USER:
self._context.chat_history.add_message(
ChatMessageContent(
role=AuthorRole.USER,
content=f"Transferred to {message.body.name}",
)
)
self._context.chat_history.add_message(message.body)
logger.debug(f"{self.id}: Running inner loop.")
await self._run_inner_loop(ctx.cancellation_token)
async def _run_outer_loop(self, cancellation_token: CancellationToken) -> None:
if self._context is None or self._task_ledger is None:
raise RuntimeError("The Magentic manager is not started yet. Make sure to send a start message first.")
# 1. Publish the rendered task ledger to the group chat.
# Need to add the task ledger to the orchestrator's chat history
# since the publisher won't receive the message it sends even though
# the publisher also subscribes to the topic.
self._context.chat_history.add_message(
ChatMessageContent(
role=AuthorRole.ASSISTANT,
content=self._task_ledger.content,
name=self.__class__.__name__,
)
)
logger.debug(f"Initial task ledger:\n{self._task_ledger.content}")
await self.publish_message(
MagenticResponseMessage(
body=self._context.chat_history.messages[-1],
),
TopicId(self._internal_topic_type, self.id.key),
cancellation_token=cancellation_token,
)
# 2. Start the inner loop.
await self._run_inner_loop(cancellation_token)
async def _run_inner_loop(self, cancellation_token: CancellationToken) -> None:
if self._context is None or self._task_ledger is None:
raise RuntimeError("The Magentic manager is not started yet. Make sure to send a start message first.")
within_limits = await self._check_within_limits()
if not within_limits:
return
self._context.round_count += 1
# 1. Create a progress ledger
current_progress_ledger = await self._manager.create_progress_ledger(self._context.model_copy(deep=True))
logger.debug(f"Current progress ledger:\n{current_progress_ledger.model_dump_json(indent=2)}")
# 2. Process the progress ledger
# 2.1 Check for task completion
if current_progress_ledger.is_request_satisfied.answer:
logger.debug("Task completed.")
await self._prepare_final_answer()
return
# 2.2 Check for stalling or looping
if not current_progress_ledger.is_progress_being_made.answer or current_progress_ledger.is_in_loop.answer:
self._context.stall_count += 1
else:
self._context.stall_count = max(0, self._context.stall_count - 1)
if self._context.stall_count > self._manager.max_stall_count:
logger.debug("Stalling detected. Resetting the task.")
self._task_ledger = await self._manager.replan(self._context.model_copy(deep=True))
await self._reset_for_outer_loop(cancellation_token)
logger.debug("Restarting outer loop.")
await self._run_outer_loop(cancellation_token)
return
# 2.3 Publish for next step
next_step = current_progress_ledger.instruction_or_question.answer
self._context.chat_history.add_message(
ChatMessageContent(
role=AuthorRole.ASSISTANT,
content=next_step if isinstance(next_step, str) else str(next_step),
name=self.__class__.__name__,
)
)
await self.publish_message(
MagenticResponseMessage(
body=self._context.chat_history.messages[-1],
),
TopicId(self._internal_topic_type, self.id.key),
cancellation_token=cancellation_token,
)
# 2.4 Request the next speaker to speak
next_speaker = current_progress_ledger.next_speaker.answer
if next_speaker not in self._participant_descriptions:
raise ValueError(f"Unknown speaker: {next_speaker}")
logger.debug(f"Magentic One manager selected agent: {next_speaker}")
await self.publish_message(
MagenticRequestMessage(agent_name=next_speaker),
TopicId(self._internal_topic_type, self.id.key),
cancellation_token=cancellation_token,
)
async def _reset_for_outer_loop(self, cancellation_token: CancellationToken) -> None:
"""Reset the context for the outer loop."""
if self._context is None:
raise RuntimeError("The Magentic manager is not started yet. Make sure to send a start message first.")
await self.publish_message(
MagenticResetMessage(),
TopicId(self._internal_topic_type, self.id.key),
cancellation_token=cancellation_token,
)
self._context.reset()
async def _prepare_final_answer(self) -> None:
"""Prepare the final answer and send it to the result callback."""
if self._context is None:
raise RuntimeError("The Magentic manager is not started yet. Make sure to send a start message first.")
final_answer = await self._manager.prepare_final_answer(self._context.model_copy(deep=True))
if self._result_callback:
await self._result_callback(final_answer)
async def _check_within_limits(self) -> bool:
"""Check if the manager is within the limits."""
if self._context is None:
raise RuntimeError("The Magentic manager is not started yet. Make sure to send a start message first.")
hit_round_limit = (
self._manager.max_round_count is not None and self._context.round_count >= self._manager.max_round_count
)
hit_reset_limit = (
self._manager.max_reset_count is not None and self._context.reset_count > self._manager.max_reset_count
)
if hit_round_limit or hit_reset_limit:
limit_type = "round" if hit_round_limit else "reset"
logger.error(f"Max {limit_type} count reached.")
# Retrieve the latest assistant content produced so far
partial_result = next(
(m for m in reversed(self._context.chat_history.messages) if m.role == AuthorRole.ASSISTANT),
None,
)
if partial_result is None:
partial_result = ChatMessageContent(
role=AuthorRole.ASSISTANT,
content=f"Stopped because the maximum {limit_type} limit was reached. No partial result available.",
name=self.__class__.__name__,
)
if self._result_callback:
await self._result_callback(partial_result)
return False
return True
# endregion MagenticManagerActor
# region MagenticAgentActor
@experimental
class MagenticAgentActor(AgentActorBase):
"""An agent actor that process messages in a Magentic One group chat."""
@message_handler
async def _handle_response_message(self, message: MagenticResponseMessage, ctx: MessageContext) -> None:
logger.debug(f"{self.id}: Received response message.")
self._message_cache.add_message(message.body)
@message_handler
async def _handle_request_message(self, message: MagenticRequestMessage, ctx: MessageContext) -> None:
if message.agent_name != self._agent.name:
return
logger.debug(f"{self.id}: Received request message.")
response = await self._invoke_agent()
logger.debug(f"{self.id} responded with {response}.")
await self.publish_message(
MagenticResponseMessage(body=response),
TopicId(self._internal_topic_type, self.id.key),
cancellation_token=ctx.cancellation_token,
)
@message_handler
async def _handle_reset_message(self, message: MagenticResetMessage, ctx: MessageContext) -> None:
"""Handle the reset message for the Magentic One group chat."""
logger.debug(f"{self.id}: Received reset message.")
self._message_cache.clear()
if self._agent_thread:
await self._agent_thread.delete()
self._agent_thread = None
# endregion MagenticAgentActor
# region MagenticOrchestration
@experimental
class MagenticOrchestration(OrchestrationBase[TIn, TOut]):
"""The Magentic One pattern orchestration."""
def __init__(
self,
members: list[Agent],
manager: MagenticManagerBase,
name: str | None = None,
description: str | None = None,
input_transform: Callable[[TIn], Awaitable[DefaultTypeAlias] | DefaultTypeAlias] | None = None,
output_transform: Callable[[DefaultTypeAlias], Awaitable[TOut] | TOut] | None = None,
agent_response_callback: Callable[[DefaultTypeAlias], Awaitable[None] | None] | None = None,
streaming_agent_response_callback: Callable[[StreamingChatMessageContent, bool], Awaitable[None] | None]
| None = None,
) -> None:
"""Initialize the Magentic One orchestration.
Args:
members (list[Agent]): A list of agents.
manager (MagenticManagerBase): The manager for the Magentic One pattern.
name (str | None): The name of the orchestration.
description (str | None): The description of the orchestration.
input_transform (Callable | None): A function that transforms the external input message.
output_transform (Callable | None): A function that transforms the internal output message.
agent_response_callback (Callable | None): A function that is called when a response is produced
by the agents.
streaming_agent_response_callback (Callable | None): A function that is called when a streaming response
is produced by the agents.
"""
self._manager = manager
for member in members:
if member.description is None:
raise ValueError("All members must have a description.")
super().__init__(
members=members,
name=name,
description=description,
input_transform=input_transform,
output_transform=output_transform,
agent_response_callback=agent_response_callback,
streaming_agent_response_callback=streaming_agent_response_callback,
)
@override
async def _start(
self,
task: DefaultTypeAlias,
runtime: CoreRuntime,
internal_topic_type: str,
cancellation_token: CancellationToken,
) -> None:
"""Start the Magentic pattern."""
if not isinstance(task, ChatMessageContent):
# Magentic One only supports ChatMessageContent as input.
raise ValueError("The task must be a ChatMessageContent object.")
target_actor_id = await runtime.get(self._get_manager_actor_type(internal_topic_type))
await runtime.send_message(
MagenticStartMessage(body=task),
target_actor_id,
cancellation_token=cancellation_token,
)
@override
async def _prepare(
self,
runtime: CoreRuntime,
internal_topic_type: str,
exception_callback: Callable[[BaseException], None],
result_callback: Callable[[DefaultTypeAlias], Awaitable[None]],
) -> None:
"""Register the actors and orchestrations with the runtime and add the required subscriptions."""
await self._register_members(runtime, internal_topic_type, exception_callback)
await self._register_manager(runtime, internal_topic_type, exception_callback, result_callback=result_callback)
await self._add_subscriptions(runtime, internal_topic_type)
async def _register_members(
self,
runtime: CoreRuntime,
internal_topic_type: str,
exception_callback: Callable[[BaseException], None],
) -> None:
"""Register the agents."""
await asyncio.gather(*[
MagenticAgentActor.register(
runtime,
self._get_agent_actor_type(agent, internal_topic_type),
lambda agent=agent: MagenticAgentActor( # type: ignore[misc]
agent,
internal_topic_type,
exception_callback,
self._agent_response_callback,
self._streaming_agent_response_callback,
),
)
for agent in self._members
])
async def _register_manager(
self,
runtime: CoreRuntime,
internal_topic_type: str,
exception_callback: Callable[[BaseException], None],
result_callback: Callable[[DefaultTypeAlias], Awaitable[None]] | None = None,
) -> None:
"""Register the group chat manager."""
await MagenticManagerActor.register(
runtime,
self._get_manager_actor_type(internal_topic_type),
lambda: MagenticManagerActor(
self._manager,
internal_topic_type=internal_topic_type,
participant_descriptions={agent.name: agent.description for agent in self._members}, # type: ignore[misc]
exception_callback=exception_callback,
result_callback=result_callback,
),
)
async def _add_subscriptions(self, runtime: CoreRuntime, internal_topic_type: str) -> None:
subscriptions: list[TypeSubscription] = []
for agent in self._members:
subscriptions.append(
TypeSubscription(internal_topic_type, self._get_agent_actor_type(agent, internal_topic_type))
)
subscriptions.append(TypeSubscription(internal_topic_type, self._get_manager_actor_type(internal_topic_type)))
await asyncio.gather(*[runtime.add_subscription(sub) for sub in subscriptions])
def _get_agent_actor_type(self, agent: Agent, internal_topic_type: str) -> str:
"""Get the actor type for an agent.
The type is appended with the internal topic type to ensure uniqueness in the runtime
that may be shared by multiple orchestrations.
"""
return f"{agent.name}_{internal_topic_type}"
def _get_manager_actor_type(self, internal_topic_type: str) -> str:
"""Get the actor type for the group chat manager.
The type is appended with the internal topic type to ensure uniqueness in the runtime
that may be shared by multiple orchestrations.
"""
return f"{MagenticManagerActor.__name__}_{internal_topic_type}"
# endregion MagenticOrchestration
@@ -0,0 +1,344 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import inspect
import json
import logging
import uuid
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from typing import Generic, Union, get_args
from pydantic import Field
from typing_extensions import TypeVar
from semantic_kernel.agents.agent import Agent
from semantic_kernel.agents.runtime.core.cancellation_token import CancellationToken
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
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
logger: logging.Logger = logging.getLogger(__name__)
DefaultTypeAlias = Union[ChatMessageContent, list[ChatMessageContent]]
TIn = TypeVar("TIn", default=DefaultTypeAlias)
TOut = TypeVar("TOut", default=DefaultTypeAlias)
@experimental
class OrchestrationResult(KernelBaseModel, Generic[TOut]):
"""The result of an invocation of an orchestration."""
background_task: asyncio.Task | None = None
value: TOut | None = None
exception: BaseException | None = None
event: asyncio.Event = Field(default_factory=asyncio.Event)
cancellation_token: CancellationToken = Field(default_factory=CancellationToken)
async def get(self, timeout: float | None = None) -> TOut:
"""Get the result of the invocation.
If a timeout is specified, the method will wait for the result for the specified time.
If the result is not available within the timeout, a TimeoutError will be raised but the
invocation will not be aborted.
Args:
timeout (int | None): The timeout (seconds) for getting the result. If None, wait indefinitely.
Returns:
TOut: The result of the invocation.
"""
if timeout is not None:
await asyncio.wait_for(self.event.wait(), timeout=timeout)
else:
await self.event.wait()
if self.value is None:
if self.cancellation_token.is_cancelled():
raise RuntimeError("The invocation was canceled before it could complete.")
if self.exception is not None:
raise self.exception
raise RuntimeError("The invocation did not produce a result.")
return self.value
def cancel(self) -> None:
"""Cancel the invocation.
This method will cancel the invocation.
Actors that have received messages will continue to process them, but no new messages will be processed.
"""
if self.cancellation_token.is_cancelled():
raise RuntimeError("The invocation has already been canceled.")
if self.event.is_set():
raise RuntimeError("The invocation has already been completed.")
self.cancellation_token.cancel()
self.event.set()
@experimental
class OrchestrationBase(ABC, Generic[TIn, TOut]):
"""Base class for multi-agent orchestration."""
t_in: type[TIn] | None = None
t_out: type[TOut] | None = None
def __init__(
self,
members: list[Agent],
name: str | None = None,
description: str | None = None,
input_transform: Callable[[TIn], Awaitable[DefaultTypeAlias] | DefaultTypeAlias] | None = None,
output_transform: Callable[[DefaultTypeAlias], Awaitable[TOut] | TOut] | None = None,
agent_response_callback: Callable[[DefaultTypeAlias], Awaitable[None] | None] | None = None,
streaming_agent_response_callback: Callable[[StreamingChatMessageContent, bool], Awaitable[None] | None]
| None = None,
) -> None:
"""Initialize the orchestration base.
Args:
members (list[Agent]): The list of agents to be used.
name (str | None): A unique name of the orchestration. If None, a unique name will be generated.
description (str | None): The description of the orchestration. If None, use a default description.
input_transform (Callable | None): A function that transforms the external input message.
output_transform (Callable | None): A function that transforms the internal output message.
agent_response_callback (Callable | None): A function that is called when a full response is produced
by the agents.
streaming_agent_response_callback (Callable | None): A function that is called when a streaming response
is produced by the agents.
"""
if not members:
raise ValueError("The members list cannot be empty.")
self._members = members
self.name = name or f"{self.__class__.__name__}_{uuid.uuid4().hex}"
self.description = description or "A multi-agent orchestration."
self._input_transform = input_transform or self._default_input_transform
self._output_transform = output_transform or self._default_output_transform
self._agent_response_callback = agent_response_callback
self._streaming_agent_response_callback = streaming_agent_response_callback
def _set_types(self) -> None:
"""Set the external input and output types from the class arguments.
This method can only be run after the class has been initialized because it relies on the
`__orig_class__` attributes to determine the type parameters.
This method will first try to get the type parameters from the class itself. The `__orig_class__`
attribute will contain the external input and output types if they are explicitly given, for example:
```
class MyOrchestration(OrchestrationBase[TIn, TOut]):
pass
my_orchestration = MyOrchestration[str, str](...)
```
If the type parameters are not explicitly given, for example when the TypeVars has defaults, for example:
```
TIn = TypeVar("TIn", default=str)
TOut = TypeVar("TOut", default=str)
class MyOrchestration(OrchestrationBase[TIn, TOut]):
pass
my_orchestration = MyOrchestration(...)
```
The type parameters can be inferred from the `__orig_bases__` attribute.
"""
if all([self.t_in is not None, self.t_out is not None]):
return
try:
args = self.__orig_class__.__args__ # type: ignore[attr-defined]
if len(args) == 1:
self.t_in = args[0]
self.t_out = DefaultTypeAlias # type: ignore[assignment]
elif len(args) == 2:
self.t_in = args[0]
self.t_out = args[1]
else:
raise TypeError("Orchestration must have two type parameters.")
except AttributeError:
args = get_args(self.__orig_bases__[0]) # type: ignore[attr-defined]
if len(args) != 2:
raise TypeError("Orchestration must be subclassed with two type parameters.")
self.t_in = args[0] if isinstance(args[0], type) else getattr(args[0], "__default__", None) # type: ignore[assignment]
self.t_out = args[1] if isinstance(args[1], type) else getattr(args[1], "__default__", None) # type: ignore[assignment]
if any([self.t_in is None, self.t_out is None]):
raise TypeError("Orchestration must have concrete types for all type parameters.")
async def invoke(
self,
task: str | DefaultTypeAlias | TIn,
runtime: CoreRuntime,
) -> OrchestrationResult[TOut]:
"""Invoke the multi-agent orchestration.
This method is non-blocking and will return immediately.
To wait for the result, use the `get` method of the `OrchestrationResult` object.
Args:
task (str, DefaultTypeAlias, TIn): The task to be executed by the agents.
runtime (CoreRuntime): The runtime environment for the agents.
"""
self._set_types()
orchestration_result = OrchestrationResult[self.t_out]() # type: ignore[name-defined]
async def result_callback(result: DefaultTypeAlias) -> None:
nonlocal orchestration_result
if inspect.iscoroutinefunction(self._output_transform):
transformed_result = await self._output_transform(result)
else:
transformed_result = self._output_transform(result)
orchestration_result.value = transformed_result
orchestration_result.event.set()
def inner_exception_callback(exception: BaseException) -> None:
nonlocal orchestration_result
orchestration_result.exception = exception
orchestration_result.event.set()
# This unique topic type is used to isolate the orchestration run from others.
internal_topic_type = uuid.uuid4().hex
await self._prepare(
runtime,
internal_topic_type=internal_topic_type,
result_callback=result_callback,
exception_callback=inner_exception_callback,
)
if isinstance(task, str):
prepared_task = ChatMessageContent(role=AuthorRole.USER, content=task)
elif isinstance(task, ChatMessageContent) or (
isinstance(task, list) and all(isinstance(item, ChatMessageContent) for item in task)
):
prepared_task = task # type: ignore[assignment]
else:
if inspect.iscoroutinefunction(self._input_transform):
prepared_task = await self._input_transform(task) # type: ignore[arg-type]
else:
prepared_task = self._input_transform(task) # type: ignore[arg-type,assignment]
background_task = asyncio.create_task(
self._start(
prepared_task,
runtime,
internal_topic_type,
orchestration_result.cancellation_token,
)
)
# Add a callback to surface any exceptions that occur during outside of the runtime.
def outer_exception_callback(task: asyncio.Task) -> None:
nonlocal orchestration_result
try:
task.result()
except BaseException as e:
orchestration_result.exception = e
orchestration_result.event.set()
background_task.add_done_callback(outer_exception_callback)
orchestration_result.background_task = background_task
return orchestration_result
@abstractmethod
async def _start(
self,
task: DefaultTypeAlias,
runtime: CoreRuntime,
internal_topic_type: str,
cancellation_token: CancellationToken,
) -> None:
"""Start the multi-agent orchestration.
Args:
task (ChatMessageContent | list[ChatMessageContent]): The task to be executed by the agents.
runtime (CoreRuntime): The runtime environment for the agents.
internal_topic_type (str): The internal topic type for the orchestration that this actor is part of.
cancellation_token (CancellationToken): The cancellation token for the orchestration.
"""
pass
@abstractmethod
async def _prepare(
self,
runtime: CoreRuntime,
internal_topic_type: str,
exception_callback: Callable[[BaseException], None],
result_callback: Callable[[DefaultTypeAlias], Awaitable[None]],
) -> None:
"""Register the actors and orchestrations with the runtime and add the required subscriptions.
Args:
runtime (CoreRuntime): The runtime environment for the agents.
internal_topic_type (str): The internal topic type for the orchestration that this actor is part of.
exception_callback (Callable): A function that is called when an exception occurs.
result_callback (Callable): A function that is called when the result is available.
"""
pass
def _default_input_transform(self, input_message: TIn) -> DefaultTypeAlias:
"""Default input transform function.
This function transforms the external input message to chat message content(s).
If the input message is already in the correct format, it is returned as is.
Args:
input_message (TIn): The input message to be transformed.
Returns:
DefaultTypeAlias: The transformed input message.
"""
if isinstance(input_message, ChatMessageContent):
return input_message
if isinstance(input_message, list) and all(isinstance(item, ChatMessageContent) for item in input_message):
return input_message
if isinstance(input_message, self.t_in): # type: ignore[arg-type]
return ChatMessageContent(
role=AuthorRole.USER,
content=json.dumps(input_message.__dict__),
)
raise TypeError(f"Invalid input message type: {type(input_message)}. Expected {self.t_in}.")
def _default_output_transform(self, output_message: DefaultTypeAlias) -> TOut:
"""Default output transform function.
This function transforms the internal output message to the external output message.
If the output message is already in the correct format, it is returned as is.
Args:
output_message (DefaultTypeAlias): The output message to be transformed.
Returns:
TOut: The transformed output message.
"""
if self.t_out == DefaultTypeAlias or self.t_out in get_args(DefaultTypeAlias):
if isinstance(output_message, ChatMessageContent) or (
isinstance(output_message, list)
and all(isinstance(item, ChatMessageContent) for item in output_message)
):
return output_message # type: ignore[return-value]
raise TypeError(f"Invalid output message type: {type(output_message)}. Expected {self.t_out}.")
if isinstance(output_message, ChatMessageContent):
return self.t_out(**json.loads(output_message.content)) # type: ignore[misc]
raise TypeError(f"Unable to transform output message of type {type(output_message)} to {self.t_out}.")
@@ -0,0 +1,150 @@
# Copyright (c) Microsoft. All rights reserved.
ORCHESTRATOR_TASK_LEDGER_FACTS_PROMPT = """Below I will present you a request.
Before we begin addressing the request, please answer the following pre-survey to the best of your ability.
Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be
a deep well to draw from.
Here is the request:
{{$task}}
Here is the pre-survey:
1. Please list any specific facts or figures that are GIVEN in the request itself. It is possible that
there are none.
2. Please list any facts that may need to be looked up, and WHERE SPECIFICALLY they might be found.
In some cases, authoritative sources are mentioned in the request itself.
3. Please list any facts that may need to be derived (e.g., via logical deduction, simulation, or computation)
4. Please list any facts that are recalled from memory, hunches, well-reasoned guesses, etc.
When answering this survey, keep in mind that "facts" will typically be specific names, dates, statistics, etc.
Your answer should use headings:
1. GIVEN OR VERIFIED FACTS
2. FACTS TO LOOK UP
3. FACTS TO DERIVE
4. EDUCATED GUESSES
DO NOT include any other headings or sections in your response. DO NOT list next steps or plans until asked to do so.
"""
ORCHESTRATOR_TASK_LEDGER_PLAN_PROMPT = """Fantastic. To address this request we have assembled the following team:
{{$team}}
Based on the team composition, and known and unknown facts, please devise a short bullet-point plan for addressing the
original request. Remember, there is no requirement to involve all team members -- a team member's particular expertise
may not be needed for this task.
"""
ORCHESTRATOR_TASK_LEDGER_FULL_PROMPT = """
We are working to address the following user request:
{{$task}}
To answer this request we have assembled the following team:
{{$team}}
Here is an initial fact sheet to consider:
{{$facts}}
Here is the plan to follow as best as possible:
{{$plan}}
"""
ORCHESTRATOR_PROGRESS_LEDGER_PROMPT = """
Recall we are working on the following request:
{{$task}}
And we have assembled the following team:
{{$team}}
To make progress on the request, please answer the following questions, including necessary reasoning:
- Is the request fully satisfied? (True if complete, or False if the original request has yet to be
SUCCESSFULLY and FULLY addressed)
- Are we in a loop where we are repeating the same requests and / or getting the same responses as before?
Loops can span multiple turns, and can include repeated actions like scrolling up or down more than a
handful of times.
- Are we making forward progress? (True if just starting, or recent messages are adding value. False if recent
messages show evidence of being stuck in a loop or if there is evidence of significant barriers to success
such as the inability to read from a required file)
- Who should speak next? (select from: {{$names}})
- What instruction or question would you give this team member? (Phrase as if speaking directly to them, and
include any specific information they may need)
Please output an answer in pure JSON format according to the following schema. The JSON object must be parsable as-is.
DO NOT OUTPUT ANYTHING OTHER THAN JSON, AND DO NOT DEVIATE FROM THIS SCHEMA:
{
"is_request_satisfied": {
"reason": string,
"answer": boolean
},
"is_in_loop": {
"reason": string,
"answer": boolean
},
"is_progress_being_made": {
"reason": string,
"answer": boolean
},
"next_speaker": {
"reason": string,
"answer": string (select from: {{$names}})
},
"instruction_or_question": {
"reason": string,
"answer": string
}
}
"""
ORCHESTRATOR_TASK_LEDGER_FACTS_UPDATE_PROMPT = """As a reminder, we are working to solve the following task:
{{$task}}
It's clear we aren't making as much progress as we would like, but we may have learned something new.
Please rewrite the following fact sheet, updating it to include anything new we have learned that may be helpful.
Example edits can include (but are not limited to) adding new guesses, moving educated guesses to verified facts
if appropriate, etc. Updates may be made to any section of the fact sheet, and more than one section of the fact
sheet can be edited. This is an especially good time to update educated guesses, so please at least add or update
one educated guess or hunch, and explain your reasoning.
Here is the old fact sheet:
{{$old_facts}}
"""
ORCHESTRATOR_TASK_LEDGER_PLAN_UPDATE_PROMPT = """Please briefly explain what went wrong on this last run (the root
cause of the failure), and then come up with a new plan that takes steps and/or includes hints to overcome prior
challenges and especially avoids repeating the same mistakes. As before, the new plan should be concise, be expressed
in bullet-point form, and consider the following team composition (do not involve any other outside people since we
cannot contact anyone else):
{{$team}}
"""
ORCHESTRATOR_FINAL_ANSWER_PROMPT = """
We are working on the following task:
{{$task}}
We have completed the task.
The above messages contain the conversation that took place to complete the task.
Based on the information gathered, provide the final answer to the original request.
The answer should be phrased as if you were speaking to the user.
"""
@@ -0,0 +1,205 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import sys
from collections.abc import Awaitable, Callable
from semantic_kernel.agents.agent import Agent
from semantic_kernel.agents.orchestration.agent_actor_base import ActorBase, AgentActorBase
from semantic_kernel.agents.orchestration.orchestration_base import DefaultTypeAlias, OrchestrationBase, TIn, TOut
from semantic_kernel.agents.runtime.core.cancellation_token import CancellationToken
from semantic_kernel.agents.runtime.core.core_runtime import CoreRuntime
from semantic_kernel.agents.runtime.core.message_context import MessageContext
from semantic_kernel.agents.runtime.core.routed_agent import message_handler
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
logger: logging.Logger = logging.getLogger(__name__)
@experimental
class SequentialRequestMessage(KernelBaseModel):
"""A request message type for sequential agents."""
body: DefaultTypeAlias
@experimental
class SequentialResultMessage(KernelBaseModel):
"""A result message type for sequential agents."""
body: ChatMessageContent
@experimental
class SequentialAgentActor(AgentActorBase):
"""A agent actor for sequential agents that process tasks."""
def __init__(
self,
agent: Agent,
internal_topic_type: str,
next_agent_type: str,
exception_callback: Callable[[BaseException], None],
agent_response_callback: Callable[[DefaultTypeAlias], Awaitable[None] | None] | None = None,
streaming_agent_response_callback: Callable[[StreamingChatMessageContent, bool], Awaitable[None] | None]
| None = None,
) -> None:
"""Initialize the agent actor."""
self._next_agent_type = next_agent_type
super().__init__(
agent=agent,
internal_topic_type=internal_topic_type,
exception_callback=exception_callback,
agent_response_callback=agent_response_callback,
streaming_agent_response_callback=streaming_agent_response_callback,
)
@message_handler
async def _handle_message(self, message: SequentialRequestMessage, ctx: MessageContext) -> None:
"""Handle a message."""
logger.debug(f"Sequential actor (Actor ID: {self.id}; Agent name: {self._agent.name}) started processing...")
response = await self._invoke_agent(additional_messages=message.body)
logger.debug(f"Sequential actor (Actor ID: {self.id}; Agent name: {self._agent.name}) finished processing.")
target_actor_id = await self.runtime.get(self._next_agent_type)
await self.send_message(
SequentialRequestMessage(body=response),
target_actor_id,
cancellation_token=ctx.cancellation_token,
)
@experimental
class CollectionActor(ActorBase):
"""A agent container for collection results from the last agent in the sequence."""
def __init__(
self,
description: str,
exception_callback: Callable[[BaseException], None],
result_callback: Callable[[DefaultTypeAlias], Awaitable[None]],
) -> None:
"""Initialize the collection actor."""
self._result_callback = result_callback
super().__init__(description, exception_callback)
@message_handler
async def _handle_message(self, message: SequentialRequestMessage, _: MessageContext) -> None:
"""Handle the last message."""
await self._result_callback(message.body)
@experimental
class SequentialOrchestration(OrchestrationBase[TIn, TOut]):
"""A sequential multi-agent pattern orchestration."""
@override
async def _start(
self,
task: DefaultTypeAlias,
runtime: CoreRuntime,
internal_topic_type: str,
cancellation_token: CancellationToken,
) -> None:
"""Start the sequential pattern."""
target_actor_id = await runtime.get(self._get_agent_actor_type(self._members[0], internal_topic_type))
await runtime.send_message(
SequentialRequestMessage(body=task),
target_actor_id,
cancellation_token=cancellation_token,
)
@override
async def _prepare(
self,
runtime: CoreRuntime,
internal_topic_type: str,
exception_callback: Callable[[BaseException], None],
result_callback: Callable[[DefaultTypeAlias], Awaitable[None]],
) -> None:
"""Register the actors and orchestrations with the runtime and add the required subscriptions."""
await self._register_members(runtime, internal_topic_type, exception_callback)
await self._register_collection_actor(runtime, internal_topic_type, exception_callback, result_callback)
async def _register_members(
self,
runtime: CoreRuntime,
internal_topic_type: str,
exception_callback: Callable[[BaseException], None],
) -> None:
"""Register the members.
The members will be registered in the reverse order so that the actor type of the next worker
is available when the current worker is registered. This is important for the sequential
orchestration, where actors need to know its next actor type to send the message to.
Args:
runtime (CoreRuntime): The agent runtime.
internal_topic_type (str): The internal topic type for the orchestration that this actor is part of.
exception_callback (Callable[[BaseException], None]): A callback function to handle exceptions.
Returns:
str: The first actor type in the sequence.
"""
next_actor_type = self._get_collection_actor_type(internal_topic_type)
for agent in reversed(self._members):
await SequentialAgentActor.register(
runtime,
self._get_agent_actor_type(agent, internal_topic_type),
lambda agent=agent, next_actor_type=next_actor_type: SequentialAgentActor( # type: ignore[misc]
agent,
internal_topic_type,
next_agent_type=next_actor_type,
exception_callback=exception_callback,
agent_response_callback=self._agent_response_callback,
streaming_agent_response_callback=self._streaming_agent_response_callback,
),
)
logger.debug(f"Registered agent actor of type {self._get_agent_actor_type(agent, internal_topic_type)}")
next_actor_type = self._get_agent_actor_type(agent, internal_topic_type)
async def _register_collection_actor(
self,
runtime: CoreRuntime,
internal_topic_type: str,
exception_callback: Callable[[BaseException], None],
result_callback: Callable[[DefaultTypeAlias], Awaitable[None]],
) -> None:
"""Register the collection actor."""
await CollectionActor.register(
runtime,
self._get_collection_actor_type(internal_topic_type),
lambda: CollectionActor(
description="An internal agent that is responsible for collection results",
exception_callback=exception_callback,
result_callback=result_callback,
),
)
def _get_agent_actor_type(self, agent: Agent, internal_topic_type: str) -> str:
"""Get the actor type for an agent.
The type is appended with the internal topic type to ensure uniqueness in the runtime
that may be shared by multiple orchestrations.
"""
return f"{agent.name}_{internal_topic_type}"
def _get_collection_actor_type(self, internal_topic_type: str) -> str:
"""Get the collection actor type.
The type is appended with the internal topic type to ensure uniqueness in the runtime
that may be shared by multiple orchestrations.
"""
return f"{CollectionActor.__name__}_{internal_topic_type}"
@@ -0,0 +1,67 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Awaitable, Callable
from pydantic import BaseModel
from semantic_kernel.agents.orchestration.orchestration_base import DefaultTypeAlias
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.kernel import Kernel
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
def structured_outputs_transform(
target_structure: type[BaseModel],
service: ChatCompletionClientBase,
prompt_execution_settings: PromptExecutionSettings | None = None,
) -> Callable[[DefaultTypeAlias], Awaitable[BaseModel]]:
"""Return a function that transforms the output of a chat completion service into a target structure.
Args:
target_structure (type): The target structure to transform the output into.
service (ChatCompletionClientBase): The chat completion service to use for the transformation. This service
must support structured output.
prompt_execution_settings (PromptExecutionSettings, optional): The settings to use for the prompt execution.
Returns:
Callable[[DefaultTypeAlias], Awaitable[BaseModel]]: A function that takes the output of
the chat completion service and transforms it into the target structure.
"""
kernel = Kernel()
kernel.add_service(service)
settings = kernel.get_prompt_execution_settings_from_service_id(service.service_id)
if prompt_execution_settings:
settings.update_from_prompt_execution_settings(prompt_execution_settings)
if not hasattr(settings, "response_format"):
raise ValueError("The service must support structured output.")
settings.response_format = target_structure
chat_history = ChatHistory(
system_message=(
"Try your best to summarize the conversation into structured format:\n"
f"{target_structure.model_json_schema()}."
),
)
async def output_transform(output: DefaultTypeAlias) -> BaseModel:
"""Transform the output of the chat completion service into the target structure."""
if isinstance(output, ChatMessageContent):
chat_history.add_message(output)
elif isinstance(output, list) and all(isinstance(item, ChatMessageContent) for item in output):
for item in output:
chat_history.add_message(item)
else:
raise ValueError(f"Output must be {DefaultTypeAlias}.")
response = await service.get_chat_message_content(chat_history, settings)
assert response is not None # nosec B101
return target_structure.model_validate_json(response.content)
return output_transform