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