# 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