Files
microsoft--semantic-kernel/python/semantic_kernel/agents/orchestration/handoffs.py
T
wehub-resource-sync b957a53def
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:21:23 +08:00

531 lines
23 KiB
Python

# 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