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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:21:23 +08:00
commit b957a53def
5423 changed files with 863745 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.kernel import Kernel
__version__ = "1.44.0"
DEFAULT_RC_VERSION = f"{__version__}-rc9"
__all__ = ["DEFAULT_RC_VERSION", "Kernel", "__version__"]
+64
View File
@@ -0,0 +1,64 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib
_AGENTS = {
"Agent": ".agent",
"AgentChat": ".group_chat.agent_chat",
"AgentGroupChat": ".group_chat.agent_group_chat",
"AgentSpec": ".agent",
"AgentRegistry": ".agent",
"AgentResponseItem": ".agent",
"AgentThread": ".agent",
"AutoGenConversableAgent": ".autogen.autogen_conversable_agent",
"AutoGenConversableAgentThread": ".autogen.autogen_conversable_agent",
"AzureAIAgent": ".azure_ai.azure_ai_agent",
"AzureAIAgentSettings": ".azure_ai.azure_ai_agent_settings",
"AzureAIAgentThread": ".azure_ai.azure_ai_agent",
"AzureAssistantAgent": ".open_ai.azure_assistant_agent",
"AssistantAgentThread": ".open_ai.openai_assistant_agent",
"AzureResponsesAgent": ".open_ai.azure_responses_agent",
"BedrockAgent": ".bedrock.bedrock_agent",
"BedrockAgentThread": ".bedrock.bedrock_agent",
"ChatCompletionAgent": ".chat_completion.chat_completion_agent",
"ChatHistoryAgentThread": ".chat_completion.chat_completion_agent",
"CopilotStudioAgent": ".copilot_studio.copilot_studio_agent",
"CopilotStudioAgentAuthMode": ".copilot_studio.copilot_studio_agent_settings",
"CopilotStudioAgentSettings": ".copilot_studio.copilot_studio_agent_settings",
"CopilotStudioAgentThread": ".copilot_studio.copilot_studio_agent",
"DeclarativeSpecMixin": ".agent",
"OpenAIAssistantAgent": ".open_ai.openai_assistant_agent",
"OpenAIResponsesAgent": ".open_ai.openai_responses_agent",
"ModelConnection": ".agent",
"ModelSpec": ".agent",
"ResponsesAgentThread": ".open_ai.openai_responses_agent",
"RunPollingOptions": ".open_ai.run_polling_options",
"register_agent_type": ".agent",
"ToolSpec": ".agent",
"ConcurrentOrchestration": ".orchestration.concurrent",
"SequentialOrchestration": ".orchestration.sequential",
"HandoffOrchestration": ".orchestration.handoffs",
"OrchestrationHandoffs": ".orchestration.handoffs",
"GroupChatOrchestration": ".orchestration.group_chat",
"RoundRobinGroupChatManager": ".orchestration.group_chat",
"BooleanResult": ".orchestration.group_chat",
"StringResult": ".orchestration.group_chat",
"MessageResult": ".orchestration.group_chat",
"GroupChatManager": ".orchestration.group_chat",
"MagenticOrchestration": ".orchestration.magentic",
"ProgressLedger": ".orchestration.magentic",
"MagenticManagerBase": ".orchestration.magentic",
"StandardMagenticManager": ".orchestration.magentic",
}
def __getattr__(name: str):
if name in _AGENTS:
submod_name = _AGENTS[name]
module = importlib.import_module(submod_name, package=__name__)
return getattr(module, name)
raise AttributeError(f"module {__name__} has no attribute {name}")
def __dir__():
return list(_AGENTS.keys())
@@ -0,0 +1,89 @@
# Copyright (c) Microsoft. All rights reserved.
from .agent import (
Agent,
AgentRegistry,
AgentResponseItem,
AgentSpec,
AgentThread,
DeclarativeSpecMixin,
ModelConnection,
ModelSpec,
ToolSpec,
register_agent_type,
)
from .autogen.autogen_conversable_agent import AutoGenConversableAgent, AutoGenConversableAgentThread
from .azure_ai.azure_ai_agent import AzureAIAgent, AzureAIAgentThread
from .azure_ai.azure_ai_agent_settings import AzureAIAgentSettings
from .bedrock.bedrock_agent import BedrockAgent, BedrockAgentThread
from .chat_completion.chat_completion_agent import ChatCompletionAgent, ChatHistoryAgentThread
from .copilot_studio.copilot_studio_agent import CopilotStudioAgent, CopilotStudioAgentThread
from .copilot_studio.copilot_studio_agent_settings import CopilotStudioAgentAuthMode, CopilotStudioAgentSettings
from .group_chat.agent_chat import AgentChat
from .group_chat.agent_group_chat import AgentGroupChat
from .open_ai.azure_assistant_agent import AzureAssistantAgent
from .open_ai.azure_responses_agent import AzureResponsesAgent
from .open_ai.openai_assistant_agent import AssistantAgentThread, OpenAIAssistantAgent
from .open_ai.openai_responses_agent import OpenAIResponsesAgent, ResponsesAgentThread
from .open_ai.run_polling_options import RunPollingOptions
from .orchestration.concurrent import ConcurrentOrchestration
from .orchestration.group_chat import (
BooleanResult,
GroupChatManager,
GroupChatOrchestration,
MessageResult,
RoundRobinGroupChatManager,
StringResult,
)
from .orchestration.handoffs import HandoffOrchestration, OrchestrationHandoffs
from .orchestration.magentic import MagenticManagerBase, MagenticOrchestration, ProgressLedger, StandardMagenticManager
from .orchestration.sequential import SequentialOrchestration
__all__ = [
"Agent",
"AgentChat",
"AgentGroupChat",
"AgentRegistry",
"AgentResponseItem",
"AgentSpec",
"AgentThread",
"AssistantAgentThread",
"AutoGenConversableAgent",
"AutoGenConversableAgentThread",
"AzureAIAgent",
"AzureAIAgentSettings",
"AzureAIAgentThread",
"AzureAssistantAgent",
"AzureResponsesAgent",
"BedrockAgent",
"BedrockAgentThread",
"BooleanResult",
"ChatCompletionAgent",
"ChatHistoryAgentThread",
"ConcurrentOrchestration",
"CopilotStudioAgent",
"CopilotStudioAgentAuthMode",
"CopilotStudioAgentSettings",
"CopilotStudioAgentThread",
"DeclarativeSpecMixin",
"GroupChatManager",
"GroupChatOrchestration",
"HandoffOrchestration",
"MagenticManagerBase",
"MagenticOrchestration",
"MessageResult",
"ModelConnection",
"ModelSpec",
"OpenAIAssistantAgent",
"OpenAIResponsesAgent",
"OrchestrationHandoffs",
"ProgressLedger",
"ResponsesAgentThread",
"RoundRobinGroupChatManager",
"RunPollingOptions",
"SequentialOrchestration",
"StandardMagenticManager",
"StringResult",
"ToolSpec",
"register_agent_type",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
## AutoGen Conversable Agent (v0.2.X)
Semantic Kernel Python supports running AutoGen Conversable Agents provided in the 0.2.X package.
### Limitations
Currently, there are some limitations to note:
- AutoGen Conversable Agents in Semantic Kernel run asynchronously and do not support streaming of agent inputs or responses.
- The `AutoGenConversableAgent` in Semantic Kernel Python cannot be configured as part of a Semantic Kernel `AgentGroupChat`. As we progress towards GA for our agent group chat patterns, we will explore ways to integrate AutoGen agents into a Semantic Kernel group chat scenario.
### Installation
Install the `semantic-kernel` package with the `autogen` extra:
```bash
pip install semantic-kernel[autogen]
```
For an example of how to integrate an AutoGen Conversable Agent using the Semantic Kernel Agent abstraction, please refer to [`autogen_conversable_agent_simple_convo.py`](../../../samples/concepts/agents/autogen_conversable_agent/autogen_conversable_agent_simple_convo.py).
@@ -0,0 +1,321 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import sys
import uuid
from collections.abc import AsyncIterable, Callable
from typing import TYPE_CHECKING, Any
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
from autogen import ConversableAgent
from semantic_kernel.agents.agent import Agent, AgentResponseItem, AgentThread
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.function_result_content import FunctionResultContent
from semantic_kernel.contents.history_reducer.chat_history_reducer import ChatHistoryReducer
from semantic_kernel.contents.text_content import TextContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.exceptions.agent_exceptions import AgentInvokeException, AgentThreadOperationException
from semantic_kernel.functions.kernel_arguments import KernelArguments
from semantic_kernel.utils.feature_stage_decorator import experimental
from semantic_kernel.utils.telemetry.agent_diagnostics.decorators import (
trace_agent_get_response,
trace_agent_invocation,
)
if TYPE_CHECKING:
from autogen.cache import AbstractCache
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
from semantic_kernel.kernel import Kernel
logger: logging.Logger = logging.getLogger(__name__)
@experimental
class AutoGenConversableAgentThread(AgentThread):
"""Azure AI Agent Thread class."""
def __init__(self, chat_history: ChatHistory | None = None, thread_id: str | None = None) -> None:
"""Initialize the AutoGenConversableAgentThread Thread.
Args:
chat_history: The chat history for the thread. If None, a new ChatHistory instance will be created.
thread_id: The ID of the thread. If None, a new thread will be created.
"""
super().__init__()
self._chat_history = chat_history or ChatHistory()
self._id = thread_id
@override
async def _create(self) -> str:
"""Starts the thread and returns its ID."""
if not self._id:
self._id = f"thread_{uuid.uuid4().hex}"
return self._id
@override
async def _delete(self) -> None:
"""Ends the current thread."""
self._chat_history.clear()
@override
async def _on_new_message(self, new_message: str | ChatMessageContent) -> None:
"""Called when a new message has been contributed to the chat."""
if isinstance(new_message, str):
new_message = ChatMessageContent(role=AuthorRole.USER, content=new_message)
if (
not new_message.metadata
or "thread_id" not in new_message.metadata
or new_message.metadata["thread_id"] != self._id
):
self._chat_history.add_message(new_message)
async def get_messages(self) -> AsyncIterable[ChatMessageContent]:
"""Retrieve the current chat history.
Returns:
An async iterable of ChatMessageContent.
"""
if self._is_deleted:
raise AgentThreadOperationException("Cannot retrieve chat history, since the thread has been deleted.")
if self._id is None:
await self.create()
for message in self._chat_history.messages:
yield message
async def reduce(self) -> ChatHistory | None:
"""Reduce the chat history to a smaller size."""
if self._id is None:
raise AgentThreadOperationException("Cannot reduce chat history, since the thread is not currently active.")
if not isinstance(self._chat_history, ChatHistoryReducer):
return None
return await self._chat_history.reduce()
@experimental
class AutoGenConversableAgent(Agent):
"""A Semantic Kernel wrapper around an AutoGen 0.2 `ConversableAgent`.
This allows one to use it as a Semantic Kernel `Agent`. Note: this agent abstraction
does not currently allow for the use of AgentGroupChat within Semantic Kernel.
"""
conversable_agent: ConversableAgent
def __init__(self, conversable_agent: ConversableAgent, **kwargs: Any) -> None:
"""Initialize the AutoGenConversableAgent.
Args:
conversable_agent: The existing AutoGen 0.2 ConversableAgent instance
kwargs: Other Agent base class arguments (e.g. name, id, instructions)
"""
args: dict[str, Any] = {
"name": conversable_agent.name,
"description": conversable_agent.description,
"instructions": conversable_agent.system_message,
"conversable_agent": conversable_agent,
}
if kwargs:
args.update(kwargs)
super().__init__(**args)
@trace_agent_get_response
@override
async def get_response(
self,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AgentResponseItem[ChatMessageContent]:
"""Get a response from the agent.
Args:
messages: The input chat message content either as a string, ChatMessageContent or
a list of strings or ChatMessageContent.
thread: The thread to use for the conversation. If None, a new thread will be created.
kwargs: Additional keyword arguments
Returns:
An AgentResponseItem of type ChatMessageContent object with the response and the thread.
"""
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: AutoGenConversableAgentThread(),
expected_type=AutoGenConversableAgentThread,
)
assert thread.id is not None # nosec
reply = await self.conversable_agent.a_generate_reply(
messages=[message.to_dict() async for message in thread.get_messages()],
**kwargs,
)
logger.info("Called AutoGenConversableAgent.a_generate_reply.")
return await self._create_reply_content(reply, thread)
@trace_agent_invocation
@override
async def invoke(
self,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
*,
thread: AgentThread | None = None,
recipient: "AutoGenConversableAgent | None" = None,
clear_history: bool = True,
silent: bool = True,
cache: "AbstractCache | None" = None,
max_turns: int | None = None,
summary_method: str | Callable | None = ConversableAgent.DEFAULT_SUMMARY_METHOD,
summary_args: dict | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseItem[ChatMessageContent]]:
"""A direct `invoke` method for the ConversableAgent.
Args:
messages: The input chat message content either as a string, ChatMessageContent or
a list of strings or ChatMessageContent.
thread: The thread to use for the conversation. If None, a new thread will be created.
recipient: The recipient ConversableAgent to chat with
clear_history: Whether to clear the chat history before starting. True by default.
silent: Whether to suppress console output. True by default.
cache: The cache to use for storing chat history
max_turns: The maximum number of turns to chat for
summary_method: The method to use for summarizing the chat
summary_args: The arguments to pass to the summary method
message: The initial message to send. If message is not provided,
the agent will wait for the user to provide the first message.
kwargs: Additional keyword arguments
Yields:
An AgentResponseItem of type ChatMessageContent object with the response and the thread.
"""
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: AutoGenConversableAgentThread(),
expected_type=AutoGenConversableAgentThread,
)
assert thread.id is not None # nosec
if summary_args is None:
summary_args = {}
if recipient is not None:
if not isinstance(recipient, AutoGenConversableAgent):
raise AgentInvokeException(
f"Invalid recipient type: {type(recipient)}. "
"Recipient must be an instance of AutoGenConversableAgent."
)
messages = [message async for message in thread.get_messages()]
chat_result = await self.conversable_agent.a_initiate_chat(
recipient=recipient.conversable_agent,
clear_history=clear_history,
silent=silent,
cache=cache,
max_turns=max_turns,
summary_method=summary_method,
summary_args=summary_args,
message=messages[-1].content, # type: ignore
**kwargs,
)
logger.info(f"Called AutoGenConversableAgent.a_initiate_chat with recipient: {recipient}.")
for message in chat_result.chat_history:
msg = AutoGenConversableAgent._to_chat_message_content(message) # type: ignore
await thread.on_new_message(msg)
yield AgentResponseItem(
message=msg,
thread=thread,
)
else:
reply = await self.conversable_agent.a_generate_reply(
messages=[message.to_dict() async for message in thread.get_messages()],
)
logger.info("Called AutoGenConversableAgent.a_generate_reply.")
yield await self._create_reply_content(reply, thread)
@override
def invoke_stream(
self,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
*,
thread: AgentThread | None = None,
kernel: "Kernel | None" = None,
arguments: KernelArguments | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseItem["StreamingChatMessageContent"]]:
"""Invoke the agent with a stream of messages."""
raise NotImplementedError("The AutoGenConversableAgent does not support streaming.")
@staticmethod
def _to_chat_message_content(message: dict[str, Any]) -> ChatMessageContent:
"""Translate an AutoGen message to a Semantic Kernel ChatMessageContent."""
items: list[TextContent | FunctionCallContent | FunctionResultContent] = []
role = AuthorRole(message.get("role"))
name: str = message.get("name", "")
content = message.get("content")
if content is not None:
text = TextContent(text=content)
items.append(text)
if role == AuthorRole.ASSISTANT:
tool_calls = message.get("tool_calls")
if tool_calls is not None:
for tool_call in tool_calls:
items.append(
FunctionCallContent(
id=tool_call.get("id"),
function_name=tool_call.get("name"),
arguments=tool_call.get("function").get("arguments"),
)
)
if role == AuthorRole.TOOL:
tool_responses = message.get("tool_responses")
if tool_responses is not None:
for tool_response in tool_responses:
items.append(
FunctionResultContent(
id=tool_response.get("tool_call_id"),
result=tool_response.get("content"),
)
)
return ChatMessageContent(role=role, items=items, name=name) # type: ignore
async def _create_reply_content(
self, reply: str | dict[str, Any], thread: AgentThread
) -> AgentResponseItem[ChatMessageContent]:
response: ChatMessageContent
if isinstance(reply, str):
response = ChatMessageContent(content=reply, role=AuthorRole.ASSISTANT)
elif isinstance(reply, dict):
response = ChatMessageContent(**reply)
else:
raise AgentInvokeException(f"Unexpected reply type from `a_generate_reply`: {type(reply)}")
await thread.on_new_message(response)
return AgentResponseItem(
message=response,
thread=thread,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,990 @@
# Copyright (c) Microsoft. All rights reserved.
import inspect
import json
import logging
import sys
from collections.abc import AsyncIterable, Awaitable, Callable, Iterable
from copy import deepcopy
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
from azure.ai.agents.models import Agent as AzureAIAgentModel
from azure.ai.agents.models import (
AzureAISearchQueryType,
AzureAISearchTool,
BingGroundingTool,
CodeInterpreterTool,
FileSearchTool,
OpenApiAnonymousAuthDetails,
OpenApiTool,
ResponseFormatJsonSchemaType,
ThreadMessageOptions,
ToolDefinition,
ToolResources,
TruncationObject,
)
from azure.ai.projects.aio import AIProjectClient
from pydantic import Field
from semantic_kernel.agents import (
Agent,
AgentResponseItem,
AgentSpec,
AgentThread,
AzureAIAgentSettings,
DeclarativeSpecMixin,
ToolSpec,
register_agent_type,
)
from semantic_kernel.agents.azure_ai.agent_thread_actions import AgentThreadActions
from semantic_kernel.agents.azure_ai.azure_ai_channel import AzureAIChannel
from semantic_kernel.agents.channels.agent_channel import AgentChannel
from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions
from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.exceptions.agent_exceptions import (
AgentInitializationException,
AgentInvokeException,
AgentThreadOperationException,
)
from semantic_kernel.functions import KernelArguments
from semantic_kernel.functions.kernel_function import TEMPLATE_FORMAT_MAP
from semantic_kernel.functions.kernel_plugin import KernelPlugin
from semantic_kernel.kernel import Kernel
from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig
from semantic_kernel.utils.feature_stage_decorator import experimental
from semantic_kernel.utils.naming import generate_random_ascii_name
from semantic_kernel.utils.telemetry.agent_diagnostics.decorators import (
trace_agent_get_response,
trace_agent_invocation,
trace_agent_streaming_invocation,
)
from semantic_kernel.utils.telemetry.user_agent import APP_INFO, SEMANTIC_KERNEL_USER_AGENT
if TYPE_CHECKING:
from azure.ai.agents.models import ToolResources
from azure.core.credentials_async import AsyncTokenCredential
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
from semantic_kernel.kernel_pydantic import KernelBaseSettings
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__)
AgentsApiResponseFormatOption = str | ResponseFormatJsonSchemaType
_T = TypeVar("_T", bound="AzureAIAgent")
# region Declarative Spec
_TOOL_BUILDERS: dict[str, Callable[[ToolSpec, Kernel | None], ToolDefinition]] = {}
def _register_tool(tool_type: str):
def decorator(fn: Callable[[ToolSpec, Kernel | None], ToolDefinition]):
_TOOL_BUILDERS[tool_type.lower()] = fn
return fn
return decorator
@_register_tool("azure_ai_search")
def _azure_ai_search(spec: ToolSpec) -> AzureAISearchTool:
opts = spec.options or {}
connections = opts.get("tool_connections")
if not connections or not isinstance(connections, list) or not connections[0]:
raise AgentInitializationException(f"Missing or malformed 'tool_connections' in: {spec}")
conn_id = connections[0]
index_name = opts.get("index_name")
if not index_name or not isinstance(index_name, str):
raise AgentInitializationException(f"Missing or malformed 'index_name' in: {spec}")
raw_query_type = opts.get("query_type", AzureAISearchQueryType.SIMPLE)
if type(raw_query_type) is str:
try:
query_type = AzureAISearchQueryType(raw_query_type.lower())
except ValueError:
raise AgentInitializationException(f"Invalid query_type '{raw_query_type}' in: {spec}")
else:
query_type = raw_query_type
filter_expr = opts.get("filter", "")
top_k = opts.get("top_k", 5)
if not isinstance(top_k, int):
raise AgentInitializationException(f"'top_k' must be an integer in: {spec}")
return AzureAISearchTool(
index_connection_id=conn_id,
index_name=index_name,
query_type=query_type,
filter=filter_expr,
top_k=top_k,
)
@_register_tool("azure_function")
def _azure_function(spec: ToolSpec) -> ToolDefinition:
# TODO(evmattso): Implement Azure Function tool support
raise NotImplementedError("Azure Function tools are not yet supported with the Azure AI Agent Declarative Spec.")
@_register_tool("bing_grounding")
def _bing_grounding(spec: ToolSpec) -> BingGroundingTool:
opts = spec.options or {}
connections = spec.options.get("tool_connections")
if not connections or not isinstance(connections, list) or not connections[0]:
raise AgentInitializationException(f"Missing or malformed 'tool_connections' in: {spec}")
conn_id = connections[0]
market = opts.get("market", "")
set_lang = opts.get("set_lang", "")
count = opts.get("count", 5)
if not isinstance(count, int):
raise AgentInitializationException(f"'count' must be an integer in: {spec}")
freshness = opts.get("freshness", "")
return BingGroundingTool(connection_id=conn_id, market=market, set_lang=set_lang, count=count, freshness=freshness)
@_register_tool("code_interpreter")
def _code_interpreter(spec: ToolSpec) -> CodeInterpreterTool:
file_ids = spec.options.get("file_ids")
return CodeInterpreterTool(file_ids=file_ids) if file_ids else CodeInterpreterTool()
@_register_tool("file_search")
def _file_search(spec: ToolSpec) -> FileSearchTool:
vector_store_ids = spec.options.get("vector_store_ids")
if not vector_store_ids or not isinstance(vector_store_ids, list) or not vector_store_ids[0]:
raise AgentInitializationException(f"Missing or malformed 'vector_store_ids' in: {spec}")
return FileSearchTool(vector_store_ids=vector_store_ids)
@_register_tool("function")
def _function(spec: ToolSpec, kernel: "Kernel") -> ToolDefinition:
def parse_fqn(fqn: str) -> tuple[str, str]:
parts = fqn.split(".")
if len(parts) != 2:
raise AgentInitializationException(f"Function `{fqn}` must be in the form `pluginName.functionName`.")
return parts[0], parts[1]
if not spec.id:
raise AgentInitializationException("Function ID is required for function tools.")
plugin_name, function_name = parse_fqn(spec.id)
funcs = kernel.get_list_of_function_metadata_filters({"included_functions": f"{plugin_name}-{function_name}"})
match len(funcs):
case 0:
raise AgentInitializationException(f"Function `{spec.id}` not found in kernel.")
case 1:
return kernel_function_metadata_to_function_call_format(funcs[0]) # type: ignore[return-value]
case _:
raise AgentInitializationException(f"Multiple definitions found for `{spec.id}`. Please remove duplicates.")
@_register_tool("openapi")
def _openapi(spec: ToolSpec) -> OpenApiTool:
opts = spec.options or {}
if not spec.id:
raise AgentInitializationException("OpenAPI tool requires a non-empty 'id' (used as name).")
if not spec.description:
raise AgentInitializationException(f"OpenAPI tool '{spec.id}' requires a 'description'.")
raw_spec = opts.get("specification")
if not raw_spec:
raise AgentInitializationException(f"OpenAPI tool '{spec.id}' is missing required 'specification' field.")
try:
parsed_spec = json.loads(raw_spec) if isinstance(raw_spec, str) else raw_spec
except json.JSONDecodeError as e:
raise AgentInitializationException(f"Invalid JSON in OpenAPI 'specification' field: {e}") from e
auth = opts.get("auth", OpenApiAnonymousAuthDetails())
return OpenApiTool(
name=spec.id,
description=spec.description,
spec=parsed_spec,
auth=auth,
default_parameters=opts.get("default_parameters"),
)
def _build_tool(spec: ToolSpec, kernel: "Kernel") -> ToolDefinition:
if not spec.type:
raise AgentInitializationException("Tool spec must include a 'type' field.")
try:
builder = _TOOL_BUILDERS[spec.type.lower()]
except KeyError as exc:
raise AgentInitializationException(f"Unsupported tool type: {spec.type}") from exc
sig = inspect.signature(builder)
return builder(spec) if len(sig.parameters) == 1 else builder(spec, kernel) # type: ignore[call-arg]
def _build_tool_resources(tool_defs: list[ToolDefinition]) -> ToolResources | None:
"""Collects tool resources from known tool types with resource needs."""
resources: dict[str, Any] = {}
for tool in tool_defs:
if isinstance(tool, CodeInterpreterTool):
resources["code_interpreter"] = tool.resources.code_interpreter
elif isinstance(tool, AzureAISearchTool):
resources["azure_ai_search"] = tool.resources.azure_ai_search
elif isinstance(tool, FileSearchTool):
resources["file_search"] = tool.resources.file_search
return ToolResources(**resources) if resources else None
# endregion
# region Thread
@experimental
class AzureAIAgentThread(AgentThread):
"""Azure AI Agent Thread class."""
def __init__(
self,
*,
client: AIProjectClient,
messages: list[ThreadMessageOptions] | None = None,
metadata: dict[str, str] | None = None,
thread_id: str | None = None,
tool_resources: "ToolResources | None" = None,
) -> None:
"""Initialize the Azure AI Agent Thread.
Args:
client: The Azure AI Project client.
messages: The messages to initialize the thread with.
metadata: The metadata for the thread.
thread_id: The ID of the thread
tool_resources: The tool resources for the thread.
"""
super().__init__()
if client is None:
raise ValueError("Client cannot be None")
self._client = client
self._id = thread_id
self._messages = messages or []
self._metadata = metadata
self._tool_resources = tool_resources
@override
async def _create(self) -> str:
"""Starts the thread and returns its ID."""
try:
response = await self._client.agents.threads.create(
messages=self._messages,
metadata=self._metadata,
tool_resources=self._tool_resources,
)
except Exception as ex:
raise AgentThreadOperationException(
"The thread could not be created due to an error response from the service."
) from ex
return response.id
@override
async def _delete(self) -> None:
"""Ends the current thread."""
if self._id is None:
raise AgentThreadOperationException("The thread cannot be deleted because it has not been created yet.")
try:
await self._client.agents.threads.delete(self._id)
except Exception as ex:
raise AgentThreadOperationException(
"The thread could not be deleted due to an error response from the service."
) from ex
@override
async def _on_new_message(self, new_message: str | ChatMessageContent) -> None:
"""Called when a new message has been contributed to the chat."""
if isinstance(new_message, str):
new_message = ChatMessageContent(role=AuthorRole.USER, content=new_message)
if (
not new_message.metadata
or "thread_id" not in new_message.metadata
or new_message.metadata["thread_id"] != self.id
):
assert self.id is not None # nosec
await AgentThreadActions.create_message(self._client, self.id, new_message)
async def get_messages(self, sort_order: Literal["asc", "desc"] = "desc") -> AsyncIterable[ChatMessageContent]:
"""Get the messages in the thread.
Args:
sort_order: The order to sort the messages in. Either "asc" or "desc".
Yields:
An AsyncIterable of ChatMessageContent of the messages in the thread.
"""
if self._is_deleted:
raise ValueError("The thread has been deleted.")
if self._id is None:
await self.create()
assert self.id is not None # nosec
async for message in AgentThreadActions.get_messages(self._client, self.id, sort_order=sort_order):
yield message
@experimental
@register_agent_type("foundry_agent")
class AzureAIAgent(DeclarativeSpecMixin, Agent):
"""Azure AI Agent class."""
client: AIProjectClient
definition: AzureAIAgentModel
polling_options: RunPollingOptions = Field(default_factory=RunPollingOptions)
channel_type: ClassVar[type[AgentChannel]] = AzureAIChannel
def __init__(
self,
*,
arguments: "KernelArguments | None" = None,
client: AIProjectClient,
definition: AzureAIAgentModel,
kernel: "Kernel | None" = None,
plugins: list[KernelPlugin | object] | dict[str, KernelPlugin | object] | None = None,
polling_options: RunPollingOptions | None = None,
prompt_template_config: "PromptTemplateConfig | None" = None,
**kwargs: Any,
) -> None:
"""Initialize the Azure AI Agent.
Args:
arguments: The KernelArguments instance
client: The AzureAI Project client. See "Quickstart: Create a new agent" guide
https://learn.microsoft.com/en-us/azure/ai-services/agents/quickstart?pivots=programming-language-python-azure
for details on how to create a new agent.
definition: The AzureAI Agent model created via the AzureAI Project client.
kernel: The Kernel instance used if invoking plugins
plugins: The plugins for the agent. If plugins are included along with a kernel, any plugins
that already exist in the kernel will be overwritten.
polling_options: The polling options for the agent.
prompt_template_config: The prompt template configuration. If this is provided along with
instructions, the prompt template will be used in place of the instructions.
**kwargs: Additional keyword arguments
"""
args: dict[str, Any] = {
"client": client,
"definition": definition,
"name": definition.name or f"azure_agent_{generate_random_ascii_name(length=8)}",
"description": definition.description,
}
if definition.id is not None:
args["id"] = definition.id
if kernel is not None:
args["kernel"] = kernel
if arguments is not None:
args["arguments"] = arguments
if (
definition.instructions
and prompt_template_config
and definition.instructions != prompt_template_config.template
):
logger.info(
f"Both `instructions` ({definition.instructions}) and `prompt_template_config` "
f"({prompt_template_config.template}) were provided. Using template in `prompt_template_config` "
"and ignoring `instructions`."
)
if plugins is not None:
args["plugins"] = plugins
if definition.instructions is not None:
args["instructions"] = definition.instructions
if prompt_template_config is not None:
args["prompt_template"] = TEMPLATE_FORMAT_MAP[prompt_template_config.template_format](
prompt_template_config=prompt_template_config
)
if prompt_template_config.template is not None:
# Use the template from the prompt_template_config if it is provided
args["instructions"] = prompt_template_config.template
if polling_options is not None:
args["polling_options"] = polling_options
if kwargs:
args.update(kwargs)
super().__init__(**args)
@staticmethod
def create_client(
credential: "AsyncTokenCredential",
endpoint: str | None = None,
api_version: str | None = None,
**kwargs: Any,
) -> AIProjectClient:
"""Create the Azure AI Project client using the connection string.
Args:
credential: The credential
endpoint: The Azure AI Foundry endpoint
api_version: Optional API version to use
kwargs: Additional keyword arguments
Returns:
AIProjectClient: The Azure AI Project client
"""
if endpoint is None:
ai_agent_settings = AzureAIAgentSettings()
if not ai_agent_settings.endpoint:
raise AgentInitializationException("Please provide a valid Azure AI endpoint.")
endpoint = ai_agent_settings.endpoint
client_kwargs: dict[str, Any] = {
**kwargs,
**({"user_agent": SEMANTIC_KERNEL_USER_AGENT} if APP_INFO else {}),
}
if api_version:
client_kwargs["api_version"] = api_version
return AIProjectClient(
credential=credential,
endpoint=endpoint,
**client_kwargs,
)
# region Declarative Spec
@override
@classmethod
async def _from_dict(
cls: type[_T],
data: dict,
*,
kernel: Kernel,
prompt_template_config: PromptTemplateConfig | None = None,
**kwargs,
) -> _T:
"""Create an Azure AI Agent from the provided dictionary.
Args:
data: The dictionary containing the agent data.
kernel: The kernel to use for the agent.
prompt_template_config: The prompt template configuration.
kwargs: Additional keyword arguments. Note: unsupported keys may raise validation errors.
Returns:
AzureAIAgent: The Azure AI Agent instance.
"""
client: AIProjectClient = kwargs.pop("client", None)
if client is None:
raise AgentInitializationException("Missing required 'client' in AzureAIAgent._from_dict()")
spec = AgentSpec.model_validate(data)
if "settings" in kwargs:
kwargs.pop("settings")
args = data.pop("arguments", None)
arguments = None
if args:
arguments = KernelArguments(**args)
# Handle arguments from kwargs, merging with any arguments from data
if "arguments" in kwargs and kwargs["arguments"] is not None:
incoming_args = kwargs["arguments"]
arguments = arguments | incoming_args if arguments is not None else incoming_args
if spec.id:
existing_definition = await client.agents.get_agent(spec.id)
# Create a mutable clone
definition = deepcopy(existing_definition)
# Selectively override attributes from spec
if spec.name is not None:
setattr(definition, "name", spec.name)
if spec.description is not None:
setattr(definition, "description", spec.description)
if spec.instructions is not None:
setattr(definition, "instructions", spec.instructions)
if spec.extras:
merged_metadata = dict(getattr(definition, "metadata", {}) or {})
merged_metadata.update(spec.extras)
setattr(definition, "metadata", merged_metadata)
return cls(
definition=definition,
client=client,
kernel=kernel,
prompt_template_config=prompt_template_config,
arguments=arguments,
**kwargs,
)
if not (spec.model and spec.model.id):
raise ValueError("model.id required when creating a new Azure AI agent")
# Build tool definitions & resources
tool_objs = [_build_tool(t, kernel) for t in spec.tools if t.type != "function"]
tool_defs = [d for tool in tool_objs for d in (tool.definitions if hasattr(tool, "definitions") else [tool])]
tool_resources = _build_tool_resources(tool_objs)
try:
agent_definition = await client.agents.create_agent(
model=spec.model.id,
name=spec.name,
description=spec.description,
instructions=spec.instructions,
tools=tool_defs,
tool_resources=tool_resources,
metadata=spec.extras,
**kwargs,
)
except Exception as ex:
print(f"Error creating agent: {ex}")
return cls(
definition=agent_definition,
client=client,
kernel=kernel,
arguments=arguments,
prompt_template_config=prompt_template_config,
**kwargs,
)
@override
@classmethod
def resolve_placeholders(
cls: type[_T],
yaml_str: str,
settings: "KernelBaseSettings | None" = None,
extras: dict[str, Any] | None = None,
) -> str:
"""Substitute ${AzureAI:Key} placeholders with fields from AzureAIAgentSettings and extras."""
import re
pattern = re.compile(r"\$\{([^}]+)\}")
# Build the mapping only if settings is provided and valid
field_mapping: dict[str, Any] = {}
if settings is None:
settings = AzureAIAgentSettings()
if not isinstance(settings, AzureAIAgentSettings):
raise AgentInitializationException(f"Expected AzureAIAgentSettings, got {type(settings).__name__}")
field_mapping.update({
"ChatModelId": getattr(settings, "model_deployment_name", None),
"Endpoint": getattr(settings, "endpoint", None),
"AgentId": getattr(settings, "agent_id", None),
"BingConnectionId": getattr(settings, "bing_connection_id", None),
"AzureAISearchConnectionId": getattr(settings, "azure_ai_search_connection_id", None),
"AzureAISearchIndexName": getattr(settings, "azure_ai_search_index_name", None),
})
if extras:
field_mapping.update(extras)
def replacer(match: re.Match[str]) -> str:
"""Replace the matched placeholder with the corresponding value from field_mapping."""
full_key = match.group(1) # for example, AzureAI:AzureAISearchConnectionId
section, _, key = full_key.partition(":")
if section != "AzureAI":
return match.group(0)
# Try short key first (AzureAISearchConnectionId), then full (AzureAI:AzureAISearchConnectionId)
return str(field_mapping.get(key) or field_mapping.get(full_key) or match.group(0))
result = pattern.sub(replacer, yaml_str)
# Safety check for unresolved placeholders
unresolved = pattern.findall(result)
if unresolved:
raise AgentInitializationException(
f"Unresolved placeholders in spec: {', '.join(f'${{{key}}}' for key in unresolved)}"
)
return result
# endregion
# region Invocation Methods
@trace_agent_get_response
@override
async def get_response(
self,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
*,
thread: AgentThread | None = None,
arguments: KernelArguments | None = None,
kernel: Kernel | None = None,
model: str | None = None,
instructions_override: str | None = None,
additional_instructions: str | None = None,
additional_messages: list[ThreadMessageOptions] | None = None,
tools: list[ToolDefinition] | None = None,
temperature: float | None = None,
top_p: float | None = None,
max_prompt_tokens: int | None = None,
max_completion_tokens: int | None = None,
truncation_strategy: TruncationObject | None = None,
response_format: AgentsApiResponseFormatOption | None = None,
parallel_tool_calls: bool | None = None,
metadata: dict[str, str] | None = None,
polling_options: RunPollingOptions | None = None,
function_choice_behavior: FunctionChoiceBehavior | None = None,
**kwargs: Any,
) -> AgentResponseItem[ChatMessageContent]:
"""Get a response from the agent on a thread.
Args:
messages: The input chat message content either as a string, ChatMessageContent or
a list of strings or ChatMessageContent.
thread: The thread to use for the agent.
arguments: The arguments for the agent.
kernel: The kernel to use for the agent.
model: The model to use for the agent.
instructions_override: Instructions to override the default instructions.
additional_instructions: Additional instructions for the agent.
additional_messages: Additional messages for the agent.
tools: Tools for the agent.
temperature: Temperature for the agent.
top_p: Top p for the agent.
max_prompt_tokens: Maximum prompt tokens for the agent.
max_completion_tokens: Maximum completion tokens for the agent.
truncation_strategy: Truncation strategy for the agent.
response_format: Response format for the agent.
parallel_tool_calls: Whether to allow parallel tool calls.
metadata: Metadata for the agent.
polling_options: The polling options for the agent.
function_choice_behavior: The function choice behavior to control which kernel
functions are available. Only Auto is supported; other types will raise an error.
**kwargs: Additional keyword arguments.
Returns:
AgentResponseItem[ChatMessageContent]: The response from the agent.
"""
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: AzureAIAgentThread(client=self.client),
expected_type=AzureAIAgentThread,
)
assert thread.id is not None # nosec
if arguments is None:
arguments = KernelArguments(**kwargs)
else:
arguments.update(kwargs)
kernel = kernel or self.kernel
arguments = self._merge_arguments(arguments)
run_level_params = {
"model": model,
"instructions_override": instructions_override,
"additional_instructions": additional_instructions,
"additional_messages": additional_messages,
"tools": tools,
"temperature": temperature,
"top_p": top_p,
"max_prompt_tokens": max_prompt_tokens,
"max_completion_tokens": max_completion_tokens,
"truncation_strategy": truncation_strategy,
"response_format": response_format,
"parallel_tool_calls": parallel_tool_calls,
"polling_options": polling_options,
"metadata": metadata,
}
run_level_params = {k: v for k, v in run_level_params.items() if v is not None}
response_messages: list[ChatMessageContent] = []
async for is_visible, response in AgentThreadActions.invoke(
agent=self,
thread_id=thread.id,
kernel=kernel,
arguments=arguments,
function_choice_behavior=function_choice_behavior,
**run_level_params, # type: ignore
):
if is_visible and response.metadata.get("code") is not True:
response.metadata["thread_id"] = thread.id
response_messages.append(response)
if not response_messages:
raise AgentInvokeException("No response messages were returned from the agent.")
final_message = response_messages[-1]
await thread.on_new_message(final_message)
return AgentResponseItem(message=final_message, thread=thread)
@trace_agent_invocation
@override
async def invoke(
self,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
*,
thread: AgentThread | None = None,
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
arguments: KernelArguments | None = None,
kernel: Kernel | None = None,
model: str | None = None,
instructions_override: str | None = None,
additional_instructions: str | None = None,
additional_messages: list[ThreadMessageOptions] | None = None,
tools: list[ToolDefinition] | None = None,
temperature: float | None = None,
top_p: float | None = None,
max_prompt_tokens: int | None = None,
max_completion_tokens: int | None = None,
truncation_strategy: TruncationObject | None = None,
response_format: AgentsApiResponseFormatOption | None = None,
parallel_tool_calls: bool | None = None,
metadata: dict[str, str] | None = None,
polling_options: RunPollingOptions | None = None,
function_choice_behavior: FunctionChoiceBehavior | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseItem[ChatMessageContent]]:
"""Invoke the agent on the specified thread.
Args:
messages: The input chat message content either as a string, ChatMessageContent or
a list of strings or ChatMessageContent.
thread: The thread to use for the agent.
on_intermediate_message: A callback function to handle intermediate steps of the agent's execution.
arguments: The arguments for the agent.
kernel: The kernel to use for the agent.
model: The model to use for the agent.
instructions_override: Instructions to override the default instructions.
additional_instructions: Additional instructions for the agent.
additional_messages: Additional messages for the agent.
tools: Tools for the agent.
temperature: Temperature for the agent.
top_p: Top p for the agent.
max_prompt_tokens: Maximum prompt tokens for the agent.
max_completion_tokens: Maximum completion tokens for the agent.
truncation_strategy: Truncation strategy for the agent.
response_format: Response format for the agent.
parallel_tool_calls: Whether to allow parallel tool calls.
polling_options: The polling options for the agent.
metadata: Metadata for the agent.
function_choice_behavior: The function choice behavior to control which kernel
functions are available. Only Auto is supported; other types will raise an error.
**kwargs: Additional keyword arguments.
Yields:
AgentResponseItem[ChatMessageContent]: The response from the agent.
"""
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: AzureAIAgentThread(client=self.client),
expected_type=AzureAIAgentThread,
)
assert thread.id is not None # nosec
if arguments is None:
arguments = KernelArguments(**kwargs)
else:
arguments.update(kwargs)
kernel = kernel or self.kernel
arguments = self._merge_arguments(arguments)
run_level_params = {
"model": model,
"instructions_override": instructions_override,
"additional_instructions": additional_instructions,
"additional_messages": additional_messages,
"tools": tools,
"temperature": temperature,
"top_p": top_p,
"max_prompt_tokens": max_prompt_tokens,
"max_completion_tokens": max_completion_tokens,
"truncation_strategy": truncation_strategy,
"response_format": response_format,
"parallel_tool_calls": parallel_tool_calls,
"metadata": metadata,
"polling_options": polling_options,
}
run_level_params = {k: v for k, v in run_level_params.items() if v is not None}
async for is_visible, message in AgentThreadActions.invoke(
agent=self,
thread_id=thread.id,
kernel=kernel,
arguments=arguments,
function_choice_behavior=function_choice_behavior,
**run_level_params, # type: ignore
):
message.metadata["thread_id"] = thread.id
await thread.on_new_message(message)
if is_visible:
# Only yield visible messages
yield AgentResponseItem(message=message, thread=thread)
elif on_intermediate_message:
# Emit tool-related messages only via callback
await on_intermediate_message(message)
@trace_agent_streaming_invocation
@override
async def invoke_stream(
self,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
*,
thread: AgentThread | None = None,
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
arguments: KernelArguments | None = None,
additional_instructions: str | None = None,
additional_messages: list[ThreadMessageOptions] | None = None,
instructions_override: str | None = None,
kernel: Kernel | None = None,
model: str | None = None,
tools: list[ToolDefinition] | None = None,
temperature: float | None = None,
top_p: float | None = None,
max_prompt_tokens: int | None = None,
max_completion_tokens: int | None = None,
truncation_strategy: TruncationObject | None = None,
response_format: AgentsApiResponseFormatOption | None = None,
parallel_tool_calls: bool | None = None,
metadata: dict[str, str] | None = None,
function_choice_behavior: FunctionChoiceBehavior | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseItem["StreamingChatMessageContent"]]:
"""Invoke the agent on the specified thread with a stream of messages.
Args:
messages: The input chat message content either as a string, ChatMessageContent or
a list of strings or ChatMessageContent.
thread: The thread to use for the agent.
on_intermediate_message: A callback function to handle intermediate steps of the
agent's execution as fully formed messages.
arguments: The arguments for the agent.
additional_instructions: Additional instructions for the agent.
additional_messages: Additional messages for the agent.
instructions_override: Instructions to override the default instructions.
kernel: The kernel to use for the agent.
model: The model to use for the agent.
tools: Tools for the agent.
temperature: Temperature for the agent.
top_p: Top p for the agent.
max_prompt_tokens: Maximum prompt tokens for the agent.
max_completion_tokens: Maximum completion tokens for the agent.
truncation_strategy: Truncation strategy for the agent.
response_format: Response format for the agent.
parallel_tool_calls: Whether to allow parallel tool calls.
metadata: Metadata for the agent.
function_choice_behavior: The function choice behavior to control which kernel
functions are available. Only Auto is supported; other types will raise an error.
**kwargs: Additional keyword arguments.
Yields:
AgentResponseItem[StreamingChatMessageContent]: The response from the agent.
"""
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: AzureAIAgentThread(client=self.client),
expected_type=AzureAIAgentThread,
)
assert thread.id is not None # nosec
if arguments is None:
arguments = KernelArguments(**kwargs)
else:
arguments.update(kwargs)
kernel = kernel or self.kernel
arguments = self._merge_arguments(arguments)
run_level_params = {
"model": model,
"instructions_override": instructions_override,
"additional_instructions": additional_instructions,
"additional_messages": additional_messages,
"tools": tools,
"temperature": temperature,
"top_p": top_p,
"max_prompt_tokens": max_prompt_tokens,
"max_completion_tokens": max_completion_tokens,
"truncation_strategy": truncation_strategy,
"response_format": response_format,
"parallel_tool_calls": parallel_tool_calls,
"metadata": metadata,
}
run_level_params = {k: v for k, v in run_level_params.items() if v is not None}
collected_messages: list[ChatMessageContent] | None = [] if on_intermediate_message else None
start_idx = 0
async for message in AgentThreadActions.invoke_stream(
agent=self,
thread_id=thread.id,
output_messages=collected_messages,
kernel=kernel,
arguments=arguments,
function_choice_behavior=function_choice_behavior,
**run_level_params, # type: ignore
):
# Before yielding the current streamed message, emit any new full messages first
if collected_messages is not None:
new_messages = collected_messages[start_idx:]
start_idx = len(collected_messages)
for new_msg in new_messages:
new_msg.metadata["thread_id"] = thread.id
await thread.on_new_message(new_msg)
if on_intermediate_message:
await on_intermediate_message(new_msg)
# Now yield the current streamed content (StreamingTextContent)
message.metadata["thread_id"] = thread.id
yield AgentResponseItem(message=message, thread=thread)
def get_channel_keys(self) -> Iterable[str]:
"""Get the channel keys.
Returns:
Iterable[str]: The channel keys.
"""
# Distinguish from other channel types.
yield f"{AzureAIAgent.__name__}"
# Distinguish between different agent IDs
yield self.id
# Distinguish between agent names
yield self.name
async def create_channel(self, thread_id: str | None = None) -> AgentChannel:
"""Create a channel.
Args:
thread_id: The ID of the thread to create the channel for. If not provided
a new thread will be created.
"""
thread = AzureAIAgentThread(client=self.client, thread_id=thread_id)
if thread.id is None:
await thread.create()
assert thread.id is not None # nosec
return AzureAIChannel(client=self.client, thread_id=thread.id)
@@ -0,0 +1,28 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import ClassVar
from semantic_kernel.kernel_pydantic import KernelBaseSettings
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class AzureAIAgentSettings(KernelBaseSettings):
"""Azure AI Agent settings currently used by the AzureAIAgent.
Args:
model_deployment_name: Azure AI Agent (Env var AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME)
endpoint: Azure AI Agent Endpoint (Env var AZURE_AI_AGENT_ENDPOINT)
api_version: Azure AI Agent API Version (Env var AZURE_AI_AGENT_API_VERSION)
"""
env_prefix: ClassVar[str] = "AZURE_AI_AGENT_"
model_deployment_name: str
endpoint: str | None = None
agent_id: str | None = None
bing_connection_id: str | None = None
azure_ai_search_connection_id: str | None = None
azure_ai_search_index_name: str | None = None
api_version: str | None = None
deep_research_model: str | None = None
@@ -0,0 +1,87 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Iterable, Sequence
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar
from azure.ai.agents.models import (
CodeInterpreterTool,
FileSearchTool,
MessageAttachment,
MessageRole,
ThreadMessageOptions,
ToolDefinition,
)
from semantic_kernel.contents.file_reference_content import FileReferenceContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.contents import ChatMessageContent
_T = TypeVar("_T", bound="AzureAIAgentUtils")
@experimental
class AzureAIAgentUtils:
"""AzureAI Agent Utility Methods."""
tool_metadata: ClassVar[dict[str, Sequence[ToolDefinition]]] = {
"file_search": FileSearchTool().definitions,
"code_interpreter": CodeInterpreterTool().definitions,
}
@classmethod
def get_thread_messages(cls: type[_T], messages: list["ChatMessageContent"]) -> Any:
"""Get the thread messages for an agent message."""
if not messages:
return None
thread_messages: list[ThreadMessageOptions] = []
for message in messages:
if not message.content:
continue
thread_msg = ThreadMessageOptions(
content=message.content,
role=MessageRole.USER if message.role == AuthorRole.USER else MessageRole.AGENT,
attachments=cls.get_attachments(message),
metadata=cls.get_metadata(message) if message.metadata else None,
)
thread_messages.append(thread_msg)
return thread_messages
@classmethod
def get_metadata(cls: type[_T], message: "ChatMessageContent") -> dict[str, str]:
"""Get the metadata for an agent message."""
return {k: str(v) if v is not None else "" for k, v in (message.metadata or {}).items()}
@classmethod
def get_attachments(cls: type[_T], message: "ChatMessageContent") -> list[MessageAttachment]:
"""Get the attachments for an agent message.
Args:
message: The ChatMessageContent
Returns:
A list of MessageAttachment
"""
return [
MessageAttachment(
file_id=file_content.file_id,
tools=list(cls._get_tool_definition(file_content.tools)), # type: ignore
data_source=file_content.data_source if file_content.data_source else None,
)
for file_content in message.items
if isinstance(file_content, FileReferenceContent)
]
@classmethod
def _get_tool_definition(cls: type[_T], tools: list[Any]) -> Iterable[ToolDefinition]:
if not tools:
return
for tool in tools:
if tool_definition := cls.tool_metadata.get(tool):
yield from tool_definition
@@ -0,0 +1,121 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from collections.abc import AsyncIterable
from typing import TYPE_CHECKING
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
from semantic_kernel.agents.azure_ai.agent_thread_actions import AgentThreadActions
from semantic_kernel.agents.channels.agent_channel import AgentChannel
from semantic_kernel.exceptions.agent_exceptions import AgentChatException
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from azure.ai.projects.aio import AIProjectClient
from semantic_kernel.agents.agent import Agent
from semantic_kernel.contents.chat_message_content import ChatMessageContent
@experimental
class AzureAIChannel(AgentChannel):
"""AzureAI Channel."""
def __init__(self, client: "AIProjectClient", thread_id: str) -> None:
"""Initialize the AzureAI Channel.
Args:
client: The AzureAI Project client.
thread_id: The thread ID for the channel.
"""
self.client = client
self.thread_id = thread_id
@override
async def receive(self, history: list["ChatMessageContent"]) -> None:
"""Receive the conversation messages.
Args:
history: The conversation messages.
"""
for message in history:
await AgentThreadActions.create_message(self.client, self.thread_id, message)
@override
async def invoke(self, agent: "Agent", **kwargs) -> AsyncIterable[tuple[bool, "ChatMessageContent"]]:
"""Invoke the agent.
Args:
agent: The agent to invoke.
kwargs: The keyword arguments.
Yields:
tuple[bool, ChatMessageContent]: The conversation messages.
"""
from semantic_kernel.agents.azure_ai.azure_ai_agent import AzureAIAgent
if not isinstance(agent, AzureAIAgent):
raise AgentChatException(f"Agent is not of the expected type {type(AzureAIAgent)}.")
async for is_visible, message in AgentThreadActions.invoke(
agent=agent,
thread_id=self.thread_id,
arguments=agent.arguments,
kernel=agent.kernel,
**kwargs,
):
yield is_visible, message
@override
async def invoke_stream(
self,
agent: "Agent",
messages: list["ChatMessageContent"],
**kwargs,
) -> AsyncIterable["ChatMessageContent"]:
"""Invoke the agent stream.
Args:
agent: The agent to invoke.
messages: The conversation messages.
kwargs: The keyword arguments.
Yields:
tuple[bool, StreamingChatMessageContent]: The conversation messages.
"""
from semantic_kernel.agents.azure_ai.azure_ai_agent import AzureAIAgent
if not isinstance(agent, AzureAIAgent):
raise AgentChatException(f"Agent is not of the expected type {type(AzureAIAgent)}.")
async for message in AgentThreadActions.invoke_stream(
agent=agent,
thread_id=self.thread_id,
output_messages=messages,
arguments=agent.arguments,
kernel=agent.kernel,
**kwargs,
):
yield message
@override
async def get_history(self) -> AsyncIterable["ChatMessageContent"]:
"""Get the conversation history.
Yields:
ChatMessageContent: The conversation history.
"""
async for message in AgentThreadActions.get_messages(self.client, thread_id=self.thread_id):
yield message
@override
async def reset(self) -> None:
"""Reset the agent's thread."""
try:
await self.client.agents.threads.delete(thread_id=self.thread_id)
except Exception as e:
raise AgentChatException(f"Failed to delete thread: {e}")
@@ -0,0 +1,27 @@
# Amazon Bedrock AI Agents in Semantic Kernel
## Overview
AWS Bedrock Agents is a managed service that allows users to stand up and run AI agents in the AWS cloud quickly.
## Tools/Functions
Bedrock Agents allow the use of tools via [action groups](https://docs.aws.amazon.com/bedrock/latest/userguide/agents-action-create.html).
The integration of Bedrock Agents with Semantic Kernel allows users to register kernel functions as tools in Bedrock Agents.
## Enable code interpretation
Bedrock Agents can write and execute code via a feature known as [code interpretation](https://docs.aws.amazon.com/bedrock/latest/userguide/agents-code-interpretation.html) similar to what OpenAI also offers.
## Enable user input
Bedrock Agents can [request user input](https://docs.aws.amazon.com/bedrock/latest/userguide/agents-user-input.html) in case of missing information to invoke a tool. When this is enabled, the agent will prompt the user for the missing information. When this is disabled, the agent will guess the missing information.
## Knowledge base
Bedrock Agents can leverage data saved on AWS to perform RAG tasks, this is referred to as the [knowledge base](https://docs.aws.amazon.com/bedrock/latest/userguide/agents-kb-add.html) in AWS.
## Multi-agent
Bedrock Agents support [multi-agent workflows](https://docs.aws.amazon.com/bedrock/latest/userguide/agents-multi-agent-collaboration.html) for more complex tasks. However, it employs a different pattern than what we have in Semantic Kernel, thus this is not supported in the current integration.
@@ -0,0 +1,117 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from semantic_kernel.connectors.ai.function_call_choice_configuration import FunctionCallChoiceConfiguration
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.function_result_content import FunctionResultContent
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata
def kernel_function_to_bedrock_function_schema(
function_choice_configuration: FunctionCallChoiceConfiguration,
) -> dict[str, Any]:
"""Convert the kernel function to bedrock function schema."""
return {
"functions": [
kernel_function_metadata_to_bedrock_function_schema(function_metadata)
for function_metadata in function_choice_configuration.available_functions or []
]
}
def kernel_function_metadata_to_bedrock_function_schema(function_metadata: KernelFunctionMetadata) -> dict[str, Any]:
"""Convert the kernel function metadata to bedrock function schema."""
schema = {
"description": function_metadata.description,
"name": function_metadata.fully_qualified_name,
"parameters": {
parameter.name: kernel_function_parameter_to_bedrock_function_parameter(parameter)
for parameter in function_metadata.parameters
},
# This field controls whether user confirmation is required to invoke the function.
# If this is set to "ENABLED", the user will be prompted to confirm the function invocation.
# Only after the user confirms, the function call request will be issued by the agent.
# If the user denies the confirmation, the agent will act as if the function does not exist.
# Currently, we do not support this feature, so we set it to "DISABLED".
"requireConfirmation": "DISABLED",
}
# Remove None values from the schema
return {key: value for key, value in schema.items() if value is not None}
def kernel_function_parameter_to_bedrock_function_parameter(parameter: KernelParameterMetadata):
"""Convert the kernel function parameters to bedrock function parameters."""
schema = {
"description": parameter.description,
"type": kernel_function_parameter_type_to_bedrock_function_parameter_type(parameter.schema_data),
"required": parameter.is_required,
}
# Remove None values from the schema
return {key: value for key, value in schema.items() if value is not None}
# These are the allowed parameter types in bedrock function.
# https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent-runtime_ParameterDetail.html
BEDROCK_FUNCTION_ALLOWED_PARAMETER_TYPES = {
"string",
"number",
"integer",
"boolean",
"array",
}
def kernel_function_parameter_type_to_bedrock_function_parameter_type(schema_data: dict[str, Any] | None) -> str:
"""Convert the kernel function parameter type to bedrock function parameter type."""
if schema_data is None:
raise ValueError(
"Schema data is required to convert the kernel function parameter type to bedrock function parameter type."
)
type_ = schema_data.get("type")
if type_ is None:
raise ValueError(
"Type is required to convert the kernel function parameter type to bedrock function parameter type."
)
if type_ not in BEDROCK_FUNCTION_ALLOWED_PARAMETER_TYPES:
raise ValueError(
f"Type {type_} is not allowed in bedrock function parameter type. "
f"Allowed types are {BEDROCK_FUNCTION_ALLOWED_PARAMETER_TYPES}."
)
return type_
def parse_return_control_payload(return_control_payload: dict[str, Any]) -> list[FunctionCallContent]:
"""Parse the return control payload to a list of function call contents for the kernel."""
return [
FunctionCallContent(
id=return_control_payload["invocationId"],
name=invocation_input["functionInvocationInput"]["function"],
arguments={
parameter["name"]: parameter["value"]
for parameter in invocation_input["functionInvocationInput"]["parameters"]
},
metadata=invocation_input,
)
for invocation_input in return_control_payload.get("invocationInputs", [])
]
def parse_function_result_contents(function_result_contents: list[FunctionResultContent]) -> list[dict[str, Any]]:
"""Parse the function result contents to be returned to the agent in the session state."""
return [
{
"functionResult": {
"actionGroup": function_result_content.metadata["functionInvocationInput"]["actionGroup"],
"function": function_result_content.name,
"responseBody": {"TEXT": {"body": str(function_result_content.result)}},
}
}
for function_result_content in function_result_contents
]
@@ -0,0 +1,746 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import os
import sys
from collections.abc import AsyncIterable, Awaitable, Callable
from functools import partial, reduce
from typing import Any, ClassVar
from pydantic import ValidationError
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
from semantic_kernel.agents import AgentResponseItem, AgentThread
from semantic_kernel.agents.bedrock.action_group_utils import (
parse_function_result_contents,
parse_return_control_payload,
)
from semantic_kernel.agents.bedrock.bedrock_agent_base import BedrockAgentBase
from semantic_kernel.agents.bedrock.bedrock_agent_settings import BedrockAgentSettings
from semantic_kernel.agents.bedrock.models.bedrock_agent_event_type import BedrockAgentEventType
from semantic_kernel.agents.bedrock.models.bedrock_agent_model import BedrockAgentModel
from semantic_kernel.agents.bedrock.models.bedrock_agent_status import BedrockAgentStatus
from semantic_kernel.agents.channels.agent_channel import AgentChannel
from semantic_kernel.agents.channels.bedrock_agent_channel import BedrockAgentChannel
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.contents.binary_content import BinaryContent
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.function_result_content import FunctionResultContent
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.exceptions.agent_exceptions import AgentInitializationException, AgentInvokeException
from semantic_kernel.functions.kernel_arguments import KernelArguments
from semantic_kernel.functions.kernel_plugin import KernelPlugin
from semantic_kernel.kernel import Kernel
from semantic_kernel.utils.async_utils import run_in_executor
from semantic_kernel.utils.feature_stage_decorator import experimental
from semantic_kernel.utils.telemetry.agent_diagnostics.decorators import (
trace_agent_get_response,
trace_agent_invocation,
trace_agent_streaming_invocation,
)
logger = logging.getLogger(__name__)
@experimental
class BedrockAgentThread(AgentThread):
"""Bedrock Agent Thread class."""
def __init__(
self,
bedrock_runtime_client: Any,
session_id: str | None = None,
) -> None:
"""Initialize the Bedrock Agent Thread.
The underlying Bedrock session of the thread is created when the thread is started.
https://docs.aws.amazon.com/bedrock/latest/userguide/sessions.html
Args:
bedrock_runtime_client: The Bedrock Runtime Client.
session_id: The session ID.
"""
super().__init__()
self._bedrock_runtime_client = bedrock_runtime_client
self._id = session_id
@override
async def _create(self) -> str:
"""Starts the thread and returns the underlying Bedrock session ID."""
response = await run_in_executor(
None,
partial(
self._bedrock_runtime_client.create_session,
),
)
self._id = response["sessionId"]
return self._id # type: ignore
@override
async def _delete(self) -> None:
"""Ends the current thread.
This will only end the underlying Bedrock session but not delete it.
"""
# Must end the session before deleting it.
await run_in_executor(
None,
partial(
self._bedrock_runtime_client.end_session,
sessionIdentifier=self._id,
),
)
@override
async def _on_new_message(self, new_message: str | ChatMessageContent) -> None:
"""Called when a new message has been contributed to the chat."""
raise NotImplementedError(
"This method is not implemented for BedrockAgentThread. "
"Messages and responses are automatically handled by the Bedrock service."
)
@experimental
class BedrockAgent(BedrockAgentBase):
"""Bedrock Agent.
Manages the interaction with Amazon Bedrock Agent Service.
"""
channel_type: ClassVar[type[AgentChannel]] = BedrockAgentChannel
def __init__(
self,
agent_model: BedrockAgentModel | dict[str, Any],
*,
function_choice_behavior: FunctionChoiceBehavior | None = None,
kernel: Kernel | None = None,
plugins: list[KernelPlugin | object] | dict[str, KernelPlugin | object] | None = None,
arguments: KernelArguments | None = None,
bedrock_runtime_client: Any | None = None,
bedrock_client: Any | None = None,
**kwargs,
) -> None:
"""Initialize the Bedrock Agent.
Note that this only creates the agent object and does not create the agent in the service.
Args:
agent_model (BedrockAgentModel | dict[str, Any]): The agent model.
function_choice_behavior (FunctionChoiceBehavior, optional): The function choice behavior for accessing
the kernel functions and filters.
kernel (Kernel, optional): The kernel to use.
plugins (list[KernelPlugin | object] | dict[str, KernelPlugin | object], optional): The plugins to use.
arguments (KernelArguments, optional): The kernel arguments.
Invoke method arguments take precedence over the arguments provided here.
bedrock_runtime_client: The Bedrock Runtime Client.
bedrock_client: The Bedrock Client.
**kwargs: Additional keyword arguments.
"""
args: dict[str, Any] = {
"agent_model": agent_model,
**kwargs,
}
if function_choice_behavior:
args["function_choice_behavior"] = function_choice_behavior
if kernel:
args["kernel"] = kernel
if plugins:
args["plugins"] = plugins
if arguments:
args["arguments"] = arguments
if bedrock_runtime_client:
args["bedrock_runtime_client"] = bedrock_runtime_client
if bedrock_client:
args["bedrock_client"] = bedrock_client
super().__init__(**args)
# region convenience class methods
@classmethod
async def create_and_prepare_agent(
cls,
name: str,
instructions: str,
*,
agent_resource_role_arn: str | None = None,
foundation_model: str | None = None,
bedrock_runtime_client: Any | None = None,
bedrock_client: Any | None = None,
kernel: Kernel | None = None,
plugins: list[KernelPlugin | object] | dict[str, KernelPlugin | object] | None = None,
function_choice_behavior: FunctionChoiceBehavior | None = None,
arguments: KernelArguments | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> "BedrockAgent":
"""Create a new agent asynchronously.
This is a convenience method that creates an instance of BedrockAgent and then creates the agent on the service.
Args:
name (str): The name of the agent.
instructions (str, optional): The instructions for the agent.
agent_resource_role_arn (str, optional): The ARN of the agent resource role.
foundation_model (str, optional): The foundation model.
bedrock_runtime_client (Any, optional): The Bedrock Runtime Client.
bedrock_client (Any, optional): The Bedrock Client.
kernel (Kernel, optional): The kernel to use.
plugins (list[KernelPlugin | object] | dict[str, KernelPlugin | object], optional): The plugins to use.
function_choice_behavior (FunctionChoiceBehavior, optional): The function choice behavior for accessing
the kernel functions and filters. Only FunctionChoiceType.AUTO is supported.
arguments (KernelArguments, optional): The kernel arguments.
prompt_template_config (PromptTemplateConfig, optional): The prompt template configuration.
env_file_path (str, optional): The path to the environment file.
env_file_encoding (str, optional): The encoding of the environment file.
Returns:
An instance of BedrockAgent with the created agent.
"""
try:
bedrock_agent_settings = BedrockAgentSettings(
agent_resource_role_arn=agent_resource_role_arn,
foundation_model=foundation_model,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
except ValidationError as e:
raise AgentInitializationException(f"Failed to initialize the Amazon Bedrock Agent settings: {e}") from e
import boto3
from botocore.exceptions import ClientError
bedrock_runtime_client = bedrock_runtime_client or boto3.client("bedrock-agent-runtime")
bedrock_client = bedrock_client or boto3.client("bedrock-agent")
try:
response = await run_in_executor(
None,
partial(
bedrock_client.create_agent,
agentName=name,
foundationModel=bedrock_agent_settings.foundation_model,
agentResourceRoleArn=bedrock_agent_settings.agent_resource_role_arn,
instruction=instructions,
),
)
except ClientError as e:
logger.error(f"Failed to create agent {name}.")
raise AgentInitializationException(f"Failed to create the Amazon Bedrock Agent: {e}") from e
bedrock_agent = cls(
response["agent"],
function_choice_behavior=function_choice_behavior,
kernel=kernel,
plugins=plugins,
arguments=arguments,
bedrock_runtime_client=bedrock_runtime_client,
bedrock_client=bedrock_client,
)
# The agent will first enter the CREATING status.
# When the operation finishes, it will enter the NOT_PREPARED status.
# We need to wait for the agent to reach the NOT_PREPARED status before we can prepare it.
await bedrock_agent._wait_for_agent_status(BedrockAgentStatus.NOT_PREPARED)
await bedrock_agent.prepare_agent_and_wait_until_prepared()
return bedrock_agent
# endregion
@trace_agent_get_response
@override
async def get_response(
self,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
*,
thread: AgentThread | None = None,
agent_alias: str | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs,
) -> AgentResponseItem[ChatMessageContent]:
"""Get a response from the agent.
Args:
messages (str | ChatMessageContent | list[str | ChatMessageContent]): The messages.
thread (AgentThread, optional): The thread. This is used to maintain the session state in the service.
agent_alias (str, optional): The agent alias.
arguments (KernelArguments, optional): The kernel arguments to override the current arguments.
kernel (Kernel, optional): The kernel to override the current kernel.
**kwargs: Additional keyword arguments.
Returns:
A chat message content with the response.
"""
if not isinstance(messages, str) and not isinstance(messages, ChatMessageContent):
raise AgentInvokeException("Messages must be a string or a ChatMessageContent for BedrockAgent.")
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: BedrockAgentThread(bedrock_runtime_client=self.bedrock_runtime_client),
expected_type=BedrockAgentThread,
)
assert thread.id is not None # nosec
if arguments is None:
arguments = KernelArguments(**kwargs)
else:
arguments.update(kwargs)
kernel = kernel or self.kernel
arguments = self._merge_arguments(arguments)
kwargs.setdefault("streamingConfigurations", {})["streamFinalResponse"] = False
kwargs.setdefault("sessionState", {})
for _ in range(self.function_choice_behavior.maximum_auto_invoke_attempts):
response = await self._invoke_agent(thread.id, messages, agent_alias, **kwargs)
events: list[dict[str, Any]] = []
for event in response.get("completion", []):
events.append(event)
if any(BedrockAgentEventType.RETURN_CONTROL in event for event in events):
# Check if there is function call requests. If there are function calls,
# parse and invoke them and return the results back to the agent.
# Not yielding the function call results back to the user.
kwargs["sessionState"].update(
await self._handle_return_control_event(
next(event for event in events if BedrockAgentEventType.RETURN_CONTROL in event),
kernel,
arguments,
)
)
else:
# For the rest of the events, the chunk will become the chat message content.
# If there are files or trace, they will be added to the chat message content.
file_items: list[BinaryContent] | None = None
trace_metadata: dict[str, Any] | None = None
chat_message_content: ChatMessageContent | None = None
for event in events:
if BedrockAgentEventType.CHUNK in event:
chat_message_content = self._handle_chunk_event(event)
elif BedrockAgentEventType.FILES in event:
file_items = self._handle_files_event(event)
elif BedrockAgentEventType.TRACE in event:
trace_metadata = self._handle_trace_event(event)
if not chat_message_content or not chat_message_content.content:
raise AgentInvokeException("Chat message content is expected but not found in the response.")
if file_items:
chat_message_content.items.extend(file_items)
if trace_metadata:
chat_message_content.metadata.update({"trace": trace_metadata})
if not chat_message_content:
raise AgentInvokeException("No response from the agent.")
chat_message_content.metadata["thread_id"] = thread.id
return AgentResponseItem(message=chat_message_content, thread=thread)
raise AgentInvokeException(
"Failed to get a response from the agent. Please consider increasing the auto invoke attempts."
)
@trace_agent_invocation
@override
async def invoke(
self,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
*,
thread: AgentThread | None = None,
on_new_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
agent_alias: str | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs,
) -> AsyncIterable[AgentResponseItem[ChatMessageContent]]:
"""Invoke an agent.
Args:
messages (str | ChatMessageContent | list[str | ChatMessageContent]): The messages.
thread (AgentThread, optional): The thread. This is used to maintain the session state in the service.
on_new_message: A callback function to handle intermediate steps of the agent's execution.
agent_alias (str, optional): The agent alias.
arguments (KernelArguments, optional): The kernel arguments to override the current arguments.
kernel (Kernel, optional): The kernel to override the current kernel.
**kwargs: Additional keyword arguments.
Returns:
An async iterable of chat message content.
"""
if not isinstance(messages, str) and not isinstance(messages, ChatMessageContent):
raise AgentInvokeException("Messages must be a string or a ChatMessageContent for BedrockAgent.")
if on_new_message:
logger.warning("The on_new_message callback is not supported for BedrockAgent.")
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: BedrockAgentThread(bedrock_runtime_client=self.bedrock_runtime_client),
expected_type=BedrockAgentThread,
)
assert thread.id is not None # nosec
if arguments is None:
arguments = KernelArguments(**kwargs)
else:
arguments.update(kwargs)
kernel = kernel or self.kernel
arguments = self._merge_arguments(arguments)
kwargs.setdefault("streamingConfigurations", {})["streamFinalResponse"] = False
kwargs.setdefault("sessionState", {})
for _ in range(self.function_choice_behavior.maximum_auto_invoke_attempts):
response = await self._invoke_agent(thread.id, messages, agent_alias, **kwargs)
events: list[dict[str, Any]] = []
for event in response.get("completion", []):
events.append(event)
if any(BedrockAgentEventType.RETURN_CONTROL in event for event in events):
# Check if there is function call requests. If there are function calls,
# parse and invoke them and return the results back to the agent.
# Not yielding the function call results back to the user.
kwargs["sessionState"].update(
await self._handle_return_control_event(
next(event for event in events if BedrockAgentEventType.RETURN_CONTROL in event),
kernel,
arguments,
)
)
else:
for event in events:
if BedrockAgentEventType.CHUNK in event:
cmc = self._handle_chunk_event(event)
cmc.metadata["thread_id"] = thread.id
yield AgentResponseItem(message=cmc, thread=thread)
elif BedrockAgentEventType.FILES in event:
cmc = ChatMessageContent(
role=AuthorRole.ASSISTANT,
items=self._handle_files_event(event), # type: ignore
name=self.name,
inner_content=event,
ai_model_id=self.agent_model.foundation_model,
)
cmc.metadata["thread_id"] = thread.id
yield AgentResponseItem(message=cmc, thread=thread)
elif BedrockAgentEventType.TRACE in event:
cmc = ChatMessageContent(
role=AuthorRole.ASSISTANT,
name=self.name,
content="",
inner_content=event,
ai_model_id=self.agent_model.foundation_model,
metadata=self._handle_trace_event(event),
)
cmc.metadata["thread_id"] = thread.id
yield AgentResponseItem(message=cmc, thread=thread)
return
raise AgentInvokeException(
"Failed to get a response from the agent. Please consider increasing the auto invoke attempts."
)
@trace_agent_streaming_invocation
@override
async def invoke_stream(
self,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
*,
thread: AgentThread | None = None,
on_new_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
agent_alias: str | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs,
) -> AsyncIterable[AgentResponseItem[StreamingChatMessageContent]]:
"""Invoke an agent with streaming.
Args:
messages (str | ChatMessageContent | list[str | ChatMessageContent]): The messages.
thread (AgentThread, optional): The thread. This is used to maintain the session state in the service.
on_new_message: A callback function to handle intermediate steps of the
agent's execution as fully formed messages.
agent_alias (str, optional): The agent alias.
arguments (KernelArguments, optional): The kernel arguments to override the current arguments.
kernel (Kernel, optional): The kernel to override the current kernel.
**kwargs: Additional keyword arguments.
Returns:
An async iterable of streaming chat message content
"""
if not isinstance(messages, str) and not isinstance(messages, ChatMessageContent):
raise AgentInvokeException("Messages must be a string or a ChatMessageContent for BedrockAgent.")
if on_new_message:
logger.warning("The on_new_message callback is not supported for BedrockAgent.")
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: BedrockAgentThread(bedrock_runtime_client=self.bedrock_runtime_client),
expected_type=BedrockAgentThread,
)
assert thread.id is not None # nosec
if arguments is None:
arguments = KernelArguments(**kwargs)
else:
arguments.update(kwargs)
kernel = kernel or self.kernel
arguments = self._merge_arguments(arguments)
kwargs.setdefault("streamingConfigurations", {})["streamFinalResponse"] = True
kwargs.setdefault("sessionState", {})
for request_index in range(self.function_choice_behavior.maximum_auto_invoke_attempts):
response = await self._invoke_agent(thread.id, messages, agent_alias, **kwargs)
all_function_call_messages: list[StreamingChatMessageContent] = []
for event in response.get("completion", []):
if BedrockAgentEventType.CHUNK in event:
scmc = self._handle_streaming_chunk_event(event)
scmc.metadata["thread_id"] = thread.id
yield AgentResponseItem(message=scmc, thread=thread)
continue
if BedrockAgentEventType.FILES in event:
scmc = self._handle_streaming_files_event(event)
scmc.metadata["thread_id"] = thread.id
yield AgentResponseItem(message=scmc, thread=thread)
continue
if BedrockAgentEventType.TRACE in event:
scmc = self._handle_streaming_trace_event(event)
scmc.metadata["thread_id"] = thread.id
yield AgentResponseItem(message=scmc, thread=thread)
continue
if BedrockAgentEventType.RETURN_CONTROL in event:
all_function_call_messages.append(self._handle_streaming_return_control_event(event))
continue
if not all_function_call_messages:
return
full_message: StreamingChatMessageContent = reduce(lambda x, y: x + y, all_function_call_messages)
function_calls = [item for item in full_message.items if isinstance(item, FunctionCallContent)]
function_result_contents = await self._handle_function_call_contents(function_calls)
kwargs["sessionState"].update({
"invocationId": function_calls[0].id,
"returnControlInvocationResults": parse_function_result_contents(function_result_contents),
})
# region non streaming Event Handlers
def _handle_chunk_event(self, event: dict[str, Any]) -> ChatMessageContent:
"""Create a chat message content."""
chunk = event[BedrockAgentEventType.CHUNK]
completion = chunk["bytes"].decode()
return ChatMessageContent(
role=AuthorRole.ASSISTANT,
content=completion,
name=self.name,
inner_content=event,
ai_model_id=self.agent_model.foundation_model,
metadata=chunk,
)
async def _handle_return_control_event(
self,
event: dict[str, Any],
kernel: Kernel,
kernel_arguments: KernelArguments,
) -> dict[str, Any]:
"""Handle return control event."""
return_control_payload = event[BedrockAgentEventType.RETURN_CONTROL]
function_calls = parse_return_control_payload(return_control_payload)
if not function_calls:
raise AgentInvokeException("Function call is expected but not found in the response.")
function_result_contents = await self._handle_function_call_contents(function_calls)
return {
"invocationId": function_calls[0].id,
"returnControlInvocationResults": parse_function_result_contents(function_result_contents),
}
def _handle_files_event(self, event: dict[str, Any]) -> list[BinaryContent]:
"""Handle file event."""
files_event = event[BedrockAgentEventType.FILES]
return [
BinaryContent(
data=file["bytes"],
data_format="base64",
mime_type=file["type"],
metadata={"name": self._sanitize_filename(file["name"])},
)
for file in files_event["files"]
]
def _handle_trace_event(self, event: dict[str, Any]) -> dict[str, Any]:
"""Handle trace event."""
return event[BedrockAgentEventType.TRACE]
# endregion
# region streaming Event Handlers
def _handle_streaming_chunk_event(self, event: dict[str, Any]) -> StreamingChatMessageContent:
"""Handle streaming chunk event."""
chunk = event[BedrockAgentEventType.CHUNK]
completion = chunk["bytes"].decode()
return StreamingChatMessageContent(
role=AuthorRole.ASSISTANT,
choice_index=0,
content=completion,
name=self.name,
inner_content=event,
ai_model_id=self.agent_model.foundation_model,
)
def _handle_streaming_return_control_event(self, event: dict[str, Any]) -> StreamingChatMessageContent:
"""Handle streaming return control event."""
return_control_payload = event[BedrockAgentEventType.RETURN_CONTROL]
function_calls = parse_return_control_payload(return_control_payload)
return StreamingChatMessageContent(
role=AuthorRole.ASSISTANT,
choice_index=0,
items=function_calls, # type: ignore
name=self.name,
inner_content=event,
ai_model_id=self.agent_model.foundation_model,
)
def _handle_streaming_files_event(self, event: dict[str, Any]) -> StreamingChatMessageContent:
"""Handle streaming file event."""
files_event = event[BedrockAgentEventType.FILES]
items: list[BinaryContent] = [
BinaryContent(
data=file["bytes"],
data_format="base64",
mime_type=file["type"],
metadata={"name": self._sanitize_filename(file["name"])},
)
for file in files_event["files"]
]
return StreamingChatMessageContent(
role=AuthorRole.ASSISTANT,
choice_index=0,
items=items, # type: ignore
name=self.name,
inner_content=event,
ai_model_id=self.agent_model.foundation_model,
)
def _handle_streaming_trace_event(self, event: dict[str, Any]) -> StreamingChatMessageContent:
"""Handle streaming trace event."""
return StreamingChatMessageContent(
role=AuthorRole.ASSISTANT,
choice_index=0,
items=[],
name=self.name,
inner_content=event,
ai_model_id=self.agent_model.foundation_model,
metadata=event[BedrockAgentEventType.TRACE],
)
# endregion
async def _handle_function_call_contents(
self,
function_call_contents: list[FunctionCallContent],
) -> list[FunctionResultContent]:
"""Handle function call contents."""
chat_history = ChatHistory()
await asyncio.gather(
*[
self.kernel.invoke_function_call(
function_call=function_call,
chat_history=chat_history,
arguments=self.arguments,
function_call_count=len(function_call_contents),
function_behavior=self.function_choice_behavior,
)
for function_call in function_call_contents
],
)
return [
item
for chat_message in chat_history.messages
for item in chat_message.items
if isinstance(item, FunctionResultContent)
]
async def create_channel(self, thread_id: str | None = None) -> AgentChannel:
"""Create a ChatHistoryChannel.
Args:
chat_history: The chat history for the channel. If None, a new ChatHistory instance will be created.
thread_id: The ID of the thread. If None, a new thread will be created.
Returns:
An instance of AgentChannel.
"""
from semantic_kernel.agents.bedrock.bedrock_agent import BedrockAgentThread
BedrockAgentChannel.model_rebuild()
thread = BedrockAgentThread(bedrock_runtime_client=self.bedrock_runtime_client, session_id=thread_id)
if thread.id is None:
await thread.create()
return BedrockAgentChannel(thread=thread)
@override
async def _notify_thread_of_new_message(self, thread, new_message):
"""Bedrock agent doesn't need to notify the thread of new messages.
The new message is passed to the agent when invoking the agent.
"""
pass
@staticmethod
def _sanitize_filename(filename: str) -> str:
"""Sanitize filename to prevent directory traversal attacks.
Args:
filename: The filename to sanitize.
Returns:
The sanitized filename with directory components removed.
"""
# Extract basename to remove any directory traversal attempts
# Handle both Unix and Windows path separators
sanitized = os.path.basename(filename.replace("\\", "/"))
# Remove any remaining path separators or null bytes
result = sanitized.replace("/", "").replace("\\", "").replace("\x00", "")
if result != filename:
logger.warning(
f"Filename contained potentially malicious path components and was sanitized: "
f"'{filename}' -> '{result}'"
)
return result
@@ -0,0 +1,381 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
from functools import partial
from typing import Any, ClassVar
import boto3
from botocore.exceptions import ClientError
from pydantic import Field, field_validator
from semantic_kernel.agents.agent import Agent
from semantic_kernel.agents.bedrock.action_group_utils import kernel_function_to_bedrock_function_schema
from semantic_kernel.agents.bedrock.models.bedrock_action_group_model import BedrockActionGroupModel
from semantic_kernel.agents.bedrock.models.bedrock_agent_model import BedrockAgentModel
from semantic_kernel.agents.bedrock.models.bedrock_agent_status import BedrockAgentStatus
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior, FunctionChoiceType
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.utils.async_utils import run_in_executor
from semantic_kernel.utils.feature_stage_decorator import experimental
logger = logging.getLogger(__name__)
@experimental
class BedrockAgentBase(Agent):
"""Bedrock Agent Base Class to provide common functionalities for Bedrock Agents."""
# There is a default alias created by Bedrock for the working draft version of the agent.
# https://docs.aws.amazon.com/bedrock/latest/userguide/agents-deploy.html
WORKING_DRAFT_AGENT_ALIAS: ClassVar[str] = "TSTALIASID"
# Amazon Bedrock Clients
# Runtime Client: Use for inference
bedrock_runtime_client: Any
# Client: Use for model management
bedrock_client: Any
# Function Choice Behavior: this is primarily used to control the behavior of the kernel when
# the agent requests functions, and to configure the kernel function action group (i.e. via filters).
# When this is None, users won't be able to create a kernel function action groups.
function_choice_behavior: FunctionChoiceBehavior = Field(default=FunctionChoiceBehavior.Auto())
# Agent Model: stores the agent information
agent_model: BedrockAgentModel
def __init__(
self,
agent_model: BedrockAgentModel | dict[str, Any],
*,
function_choice_behavior: FunctionChoiceBehavior | None = None,
bedrock_runtime_client: Any | None = None,
bedrock_client: Any | None = None,
**kwargs,
) -> None:
"""Initialize the Bedrock Agent Base.
Args:
agent_model: The Bedrock Agent Model.
function_choice_behavior: The function choice behavior.
bedrock_client: The Bedrock Client.
bedrock_runtime_client: The Bedrock Runtime Client.
kwargs: Additional keyword arguments.
"""
agent_model = (
agent_model if isinstance(agent_model, BedrockAgentModel) else BedrockAgentModel.model_validate(agent_model)
)
args = {
"agent_model": agent_model,
"id": agent_model.agent_id,
"name": agent_model.agent_name,
"bedrock_runtime_client": bedrock_runtime_client or boto3.client("bedrock-agent-runtime"),
"bedrock_client": bedrock_client or boto3.client("bedrock-agent"),
**kwargs,
}
if function_choice_behavior:
args["function_choice_behavior"] = function_choice_behavior
super().__init__(**args)
@field_validator("function_choice_behavior", mode="after")
@classmethod
def validate_function_choice_behavior(
cls, function_choice_behavior: FunctionChoiceBehavior | None
) -> FunctionChoiceBehavior | None:
"""Validate the function choice behavior."""
if function_choice_behavior and function_choice_behavior.type_ != FunctionChoiceType.AUTO:
# Users cannot specify REQUIRED or NONE for the Bedrock agents.
# Please note that the function choice behavior only control if the kernel will automatically
# execute the functions the agent requests. It does not control the behavior of the agent.
raise ValueError("Only FunctionChoiceType.AUTO is supported.")
return function_choice_behavior
def __repr__(self):
"""Return the string representation of the Bedrock Agent."""
return f"{self.agent_model}"
# region Agent Management
async def prepare_agent_and_wait_until_prepared(self) -> None:
"""Prepare the agent for use."""
if not self.agent_model.agent_id:
raise ValueError("Agent does not exist. Please create the agent before preparing it.")
try:
await run_in_executor(
None,
partial(
self.bedrock_client.prepare_agent,
agentId=self.agent_model.agent_id,
),
)
# The agent will take some time to enter the PREPARING status after the prepare operation is called.
# We need to wait for the agent to reach the PREPARING status before we can proceed, otherwise we
# will return immediately if the agent is already in PREPARED status.
await self._wait_for_agent_status(BedrockAgentStatus.PREPARING)
# The agent will enter the PREPARED status when the preparation is complete.
await self._wait_for_agent_status(BedrockAgentStatus.PREPARED)
except ClientError as e:
logger.error(f"Failed to prepare agent {self.agent_model.agent_id}.")
raise e
async def delete_agent(self, **kwargs) -> None:
"""Delete an agent asynchronously."""
if not self.agent_model.agent_id:
raise ValueError("Agent does not exist. Please create the agent before deleting it.")
try:
await run_in_executor(
None,
partial(
self.bedrock_client.delete_agent,
agentId=self.agent_model.agent_id,
**kwargs,
),
)
self.agent_model.agent_id = None
except ClientError as e:
logger.error(f"Failed to delete agent {self.agent_model.agent_id}.")
raise e
async def _get_agent(self) -> None:
"""Get an agent."""
if not self.agent_model.agent_id:
raise ValueError("Agent does not exist. Please create the agent before getting it.")
try:
response = await run_in_executor(
None,
partial(
self.bedrock_client.get_agent,
agentId=self.agent_model.agent_id,
),
)
# Update the agent model
self.agent_model = BedrockAgentModel(**response["agent"])
except ClientError as e:
logger.error(f"Failed to get agent {self.agent_model.agent_id}.")
raise e
async def _wait_for_agent_status(
self,
status: BedrockAgentStatus,
interval: int = 2,
max_attempts: int = 5,
) -> None:
"""Wait for the agent to reach a specific status."""
for _ in range(max_attempts):
await self._get_agent()
if self.agent_model.agent_status == status:
return
await asyncio.sleep(interval)
raise TimeoutError(
f"Agent did not reach status {status} within the specified time."
f" Current status: {self.agent_model.agent_status}"
)
# endregion Agent Management
# region Action Group Management
async def create_code_interpreter_action_group(self, **kwargs) -> BedrockActionGroupModel:
"""Create a code interpreter action group."""
if not self.agent_model.agent_id:
raise ValueError("Agent does not exist. Please create the agent before creating an action group for it.")
try:
response = await run_in_executor(
None,
partial(
self.bedrock_client.create_agent_action_group,
agentId=self.agent_model.agent_id,
agentVersion=self.agent_model.agent_version or "DRAFT",
actionGroupName=f"{self.agent_model.agent_name}_code_interpreter",
actionGroupState="ENABLED",
parentActionGroupSignature="AMAZON.CodeInterpreter",
**kwargs,
),
)
await self.prepare_agent_and_wait_until_prepared()
return BedrockActionGroupModel(**response["agentActionGroup"])
except ClientError as e:
logger.error(f"Failed to create code interpreter action group for agent {self.agent_model.agent_id}.")
raise e
async def create_user_input_action_group(self, **kwargs) -> BedrockActionGroupModel:
"""Create a user input action group."""
if not self.agent_model.agent_id:
raise ValueError("Agent does not exist. Please create the agent before creating an action group for it.")
try:
response = await run_in_executor(
None,
partial(
self.bedrock_client.create_agent_action_group,
agentId=self.agent_model.agent_id,
agentVersion=self.agent_model.agent_version or "DRAFT",
actionGroupName=f"{self.agent_model.agent_name}_user_input",
actionGroupState="ENABLED",
parentActionGroupSignature="AMAZON.UserInput",
**kwargs,
),
)
await self.prepare_agent_and_wait_until_prepared()
return BedrockActionGroupModel(**response["agentActionGroup"])
except ClientError as e:
logger.error(f"Failed to create user input action group for agent {self.agent_model.agent_id}.")
raise e
async def create_kernel_function_action_group(self, **kwargs) -> BedrockActionGroupModel | None:
"""Create a kernel function action group."""
if not self.agent_model.agent_id:
raise ValueError("Agent does not exist. Please create the agent before creating an action group for it.")
function_call_choice_config = self.function_choice_behavior.get_config(self.kernel)
if not function_call_choice_config.available_functions:
logger.warning("No available functions. Skipping kernel function action group creation.")
return None
try:
response = await run_in_executor(
None,
partial(
self.bedrock_client.create_agent_action_group,
agentId=self.agent_model.agent_id,
agentVersion=self.agent_model.agent_version or "DRAFT",
actionGroupName=f"{self.agent_model.agent_name}_kernel_function",
actionGroupState="ENABLED",
actionGroupExecutor={"customControl": "RETURN_CONTROL"},
functionSchema=kernel_function_to_bedrock_function_schema(function_call_choice_config),
**kwargs,
),
)
await self.prepare_agent_and_wait_until_prepared()
return BedrockActionGroupModel(**response["agentActionGroup"])
except ClientError as e:
logger.error(f"Failed to create kernel function action group for agent {self.agent_model.agent_id}.")
raise e
# endregion Action Group Management
# region Knowledge Base Management
async def associate_agent_knowledge_base(self, knowledge_base_id: str, **kwargs) -> dict[str, Any]:
"""Associate an agent with a knowledge base."""
if not self.agent_model.agent_id:
raise ValueError(
"Agent does not exist. Please create the agent before associating it with a knowledge base."
)
try:
response = await run_in_executor(
None,
partial(
self.bedrock_client.associate_agent_knowledge_base,
agentId=self.agent_model.agent_id,
agentVersion=self.agent_model.agent_version,
knowledgeBaseId=knowledge_base_id,
**kwargs,
),
)
await self.prepare_agent_and_wait_until_prepared()
return response
except ClientError as e:
logger.error(
f"Failed to associate agent {self.agent_model.agent_id} with knowledge base {knowledge_base_id}."
)
raise e
async def disassociate_agent_knowledge_base(self, knowledge_base_id: str, **kwargs) -> None:
"""Disassociate an agent with a knowledge base."""
if not self.agent_model.agent_id:
raise ValueError(
"Agent does not exist. Please create the agent before disassociating it with a knowledge base."
)
try:
response = await run_in_executor(
None,
partial(
self.bedrock_client.disassociate_agent_knowledge_base,
agentId=self.agent_model.agent_id,
agentVersion=self.agent_model.agent_version,
knowledgeBaseId=knowledge_base_id,
**kwargs,
),
)
await self.prepare_agent_and_wait_until_prepared()
return response
except ClientError as e:
logger.error(
f"Failed to disassociate agent {self.agent_model.agent_id} with knowledge base {knowledge_base_id}."
)
raise e
async def list_associated_agent_knowledge_bases(self, **kwargs) -> dict[str, Any]:
"""List associated knowledge bases with an agent."""
if not self.agent_model.agent_id:
raise ValueError("Agent does not exist. Please create the agent before listing associated knowledge bases.")
try:
return await run_in_executor(
None,
partial(
self.bedrock_client.list_agent_knowledge_bases,
agentId=self.agent_model.agent_id,
agentVersion=self.agent_model.agent_version,
**kwargs,
),
)
except ClientError as e:
logger.error(f"Failed to list associated knowledge bases for agent {self.agent_model.agent_id}.")
raise e
# endregion Knowledge Base Management
async def _invoke_agent(
self,
thread_id: str,
message: str | ChatMessageContent,
agent_alias: str | None = None,
**kwargs,
) -> dict[str, Any]:
"""Invoke an agent."""
if not self.agent_model.agent_id:
raise ValueError("Agent does not exist. Please create the agent before invoking it.")
if isinstance(message, ChatMessageContent) and message.role != AuthorRole.USER:
raise ValueError("Only user messages are supported for invoking a Bedrock agent.")
agent_alias = agent_alias or self.WORKING_DRAFT_AGENT_ALIAS
try:
return await run_in_executor(
None,
partial(
self.bedrock_runtime_client.invoke_agent,
agentAliasId=agent_alias,
agentId=self.agent_model.agent_id,
sessionId=thread_id,
inputText=message if isinstance(message, str) else message.content,
**kwargs,
),
)
except ClientError as e:
logger.error(f"Failed to invoke agent {self.agent_model.agent_id}.")
raise e
@@ -0,0 +1,32 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import ClassVar
from semantic_kernel.kernel_pydantic import KernelBaseSettings
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class BedrockAgentSettings(KernelBaseSettings):
"""Amazon Bedrock Agent service settings.
The settings are first loaded from environment variables with
the prefix 'BEDROCK_AGENT_'.
If the environment variables are not found, the settings can
be loaded from a .env file with the encoding 'utf-8'.
If the settings are not found in the .env file, the settings
are ignored; however, validation will fail alerting that the
settings are missing.
Optional settings for prefix 'BEDROCK_' are:
- agent_resource_role_arn: str - The Amazon Bedrock agent resource role ARN.
https://docs.aws.amazon.com/bedrock/latest/userguide/getting-started.html
(Env var BEDROCK_AGENT_AGENT_RESOURCE_ROLE_ARN)
- foundation_model: str - The Amazon Bedrock foundation model ID to use.
(Env var BEDROCK_AGENT_FOUNDATION_MODEL)
"""
env_prefix: ClassVar[str] = "BEDROCK_AGENT_"
agent_resource_role_arn: str
foundation_model: str
@@ -0,0 +1,21 @@
# Copyright (c) Microsoft. All rights reserved.
from pydantic import ConfigDict, Field
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class BedrockActionGroupModel(KernelBaseModel):
"""Bedrock Action Group Model.
Model field definitions for the Amazon Bedrock Action Group Service:
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/bedrock-agent/client/create_agent_action_group.html
"""
# This model_config will merge with the KernelBaseModel.model_config
model_config = ConfigDict(extra="allow")
action_group_id: str = Field(..., alias="actionGroupId", description="The unique identifier of the action group.")
action_group_name: str = Field(..., alias="actionGroupName", description="The name of the action group.")
@@ -0,0 +1,19 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class BedrockAgentEventType(str, Enum):
"""Bedrock Agent Event Type."""
# Contains the text response from the agent.
CHUNK = "chunk"
# Contains the trace information (reasoning process) from the agent.
TRACE = "trace"
# Contains the function call requests from the agent.
RETURN_CONTROL = "returnControl"
# Contains the files generated by the agent using the code interpreter.
FILES = "files"
@@ -0,0 +1,24 @@
# Copyright (c) Microsoft. All rights reserved.
from pydantic import ConfigDict, Field
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class BedrockAgentModel(KernelBaseModel):
"""Bedrock Agent Model.
Model field definitions for the Amazon Bedrock Agent Service:
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/bedrock-agent/client/create_agent.html
"""
# This model_config will merge with the KernelBaseModel.model_config
model_config = ConfigDict(extra="allow")
agent_id: str | None = Field(default=None, alias="agentId", description="The unique identifier of the agent.")
agent_name: str | None = Field(default=None, alias="agentName", description="The name of the agent.")
agent_version: str | None = Field(default=None, alias="agentVersion", description="The version of the agent.")
foundation_model: str | None = Field(default=None, alias="foundationModel", description="The foundation model.")
agent_status: str | None = Field(default=None, alias="agentStatus", description="The status of the agent.")
@@ -0,0 +1,23 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class BedrockAgentStatus(str, Enum):
"""Bedrock Agent Status.
https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent_PrepareAgent.html#API_agent_PrepareAgent_ResponseElements
"""
CREATING = "CREATING"
PREPARING = "PREPARING"
PREPARED = "PREPARED"
NOT_PREPARED = "NOT_PREPARED"
DELETING = "DELETING"
FAILED = "FAILED"
VERSIONING = "VERSIONING"
UPDATING = "UPDATING"
@@ -0,0 +1,85 @@
# Copyright (c) Microsoft. All rights reserved.
from abc import ABC, abstractmethod
from collections.abc import AsyncIterable
from typing import TYPE_CHECKING, Any
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.agents.agent import Agent
from semantic_kernel.contents.chat_message_content import ChatMessageContent
@experimental
class AgentChannel(ABC):
"""Defines the communication protocol for a particular Agent type.
An agent provides it own AgentChannel via CreateChannel.
"""
@abstractmethod
async def receive(
self,
history: list["ChatMessageContent"],
) -> None:
"""Receive the conversation messages.
Used when joining a conversation and also during each agent interaction.
Args:
history: The history of messages in the conversation.
"""
...
@abstractmethod
def invoke(
self,
agent: "Agent",
**kwargs: Any,
) -> AsyncIterable[tuple[bool, "ChatMessageContent"]]:
"""Perform a discrete incremental interaction between a single Agent and AgentChat.
Args:
agent: The agent to interact with.
kwargs: The keyword arguments.
Returns:
An async iterable of a bool, ChatMessageContent.
"""
...
@abstractmethod
def invoke_stream(
self,
agent: "Agent",
messages: "list[ChatMessageContent]",
**kwargs: Any,
) -> AsyncIterable["ChatMessageContent"]:
"""Perform a discrete incremental stream interaction between a single Agent and AgentChat.
Args:
agent: The agent to interact with.
messages: The history of messages in the conversation.
kwargs: The keyword arguments.
Returns:
An async iterable ChatMessageContent.
"""
...
@abstractmethod
def get_history(
self,
) -> AsyncIterable["ChatMessageContent"]:
"""Retrieve the message history specific to this channel.
Returns:
An async iterable of ChatMessageContent.
"""
...
@abstractmethod
async def reset(self) -> None:
"""Reset any persistent state associated with the channel."""
...
@@ -0,0 +1,217 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import sys
from collections.abc import AsyncIterable
from typing import TYPE_CHECKING, Any, ClassVar
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
from semantic_kernel.agents.agent import Agent
from semantic_kernel.agents.channels.agent_channel import AgentChannel
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.exceptions.agent_exceptions import AgentChatException
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.agents.bedrock.bedrock_agent import BedrockAgentThread
logger = logging.getLogger(__name__)
@experimental
class BedrockAgentChannel(AgentChannel, ChatHistory):
"""An AgentChannel for a BedrockAgent that is based on a ChatHistory.
The chat history will override the session state when invoking the agent.
This channel allows Bedrock agents to interact with other types of agents in Semantic Kernel in an AgentGroupChat.
However, since Bedrock agents require the chat history to alternate between user and agent messages, this channel
will preprocess the chat history to ensure that it meets the requirements of the Bedrock agent. When an invalid
pattern is detected, the channel will insert a placeholder user or assistant message to ensure that the chat history
alternates between user and agent messages.
"""
thread: "BedrockAgentThread"
MESSAGE_PLACEHOLDER: ClassVar[str] = "[SILENCE]"
@override
async def invoke(self, agent: "Agent", **kwargs: Any) -> AsyncIterable[tuple[bool, ChatMessageContent]]:
"""Perform a discrete incremental interaction between a single Agent and AgentChat.
Args:
agent: The agent to interact with.
kwargs: Additional keyword arguments.
Returns:
An async iterable of ChatMessageContent with a boolean indicating if the
message should be visible external to the agent.
"""
from semantic_kernel.agents.bedrock.bedrock_agent import BedrockAgent
if not isinstance(agent, BedrockAgent):
raise AgentChatException(f"Agent is not of the expected type {type(BedrockAgent)}.")
if not self.messages:
# This is not supposed to happen, as the channel won't get invoked
# before it has received messages. This is just extra safety.
raise AgentChatException("No chat history available.")
# Preprocess chat history
await self._ensure_history_alternates()
await self._ensure_last_message_is_user()
async for response in agent.invoke(
messages=self.messages[-1].content,
thread=self.thread,
sessionState=await self._parse_chat_history_to_session_state(),
):
# All messages from Bedrock agents are user facing, i.e., function calls are not returned as messages
self.messages.append(response.message)
yield True, response.message
@override
async def invoke_stream(
self,
agent: "Agent",
messages: list[ChatMessageContent],
**kwargs: Any,
) -> AsyncIterable[ChatMessageContent]:
"""Perform a streaming interaction between a single Agent and AgentChat.
Args:
agent: The agent to interact with.
messages: The history of messages in the conversation.
kwargs: Additional keyword arguments.
Returns:
An async iterable of ChatMessageContent.
"""
from semantic_kernel.agents.bedrock.bedrock_agent import BedrockAgent
if not isinstance(agent, BedrockAgent):
raise AgentChatException(f"Agent is not of the expected type {type(BedrockAgent)}.")
if not self.messages:
raise AgentChatException("No chat history available.")
# Preprocess chat history
await self._ensure_history_alternates()
await self._ensure_last_message_is_user()
full_message: list[StreamingChatMessageContent] = []
async for response_chunk in agent.invoke_stream(
messages=self.messages[-1].content,
thread=self.thread,
sessionState=await self._parse_chat_history_to_session_state(),
):
yield response_chunk.message
full_message.append(response_chunk.message)
messages.append(
ChatMessageContent(
role=AuthorRole.ASSISTANT,
content="".join([message.content for message in full_message]),
name=agent.name,
inner_content=full_message,
ai_model_id=agent.agent_model.foundation_model,
)
)
@override
async def receive(
self,
history: list[ChatMessageContent],
) -> None:
"""Receive the conversation messages.
Bedrock requires the chat history to alternate between user and agent messages.
Thus, when receiving the history, the message sequence will be mutated by inserting
empty agent or user messages as needed.
Args:
history: The history of messages in the conversation.
"""
for incoming_message in history:
if not self.messages or self.messages[-1].role != incoming_message.role:
self.messages.append(incoming_message)
else:
self.messages.append(
ChatMessageContent(
role=AuthorRole.ASSISTANT if incoming_message.role == AuthorRole.USER else AuthorRole.USER,
content=self.MESSAGE_PLACEHOLDER,
)
)
self.messages.append(incoming_message)
@override
async def get_history( # type: ignore
self,
) -> AsyncIterable[ChatMessageContent]:
"""Retrieve the message history specific to this channel.
Returns:
An async iterable of ChatMessageContent.
"""
for message in reversed(self.messages):
yield message
@override
async def reset(self) -> None:
"""Reset the channel state."""
self.messages.clear()
# region chat history preprocessing and parsing
async def _ensure_history_alternates(self):
"""Ensure that the chat history alternates between user and agent messages."""
if not self.messages or len(self.messages) == 1:
return
current_index = 1
while current_index < len(self.messages):
if self.messages[current_index].role == self.messages[current_index - 1].role:
self.messages.insert(
current_index,
ChatMessageContent(
role=AuthorRole.ASSISTANT
if self.messages[current_index].role == AuthorRole.USER
else AuthorRole.USER,
content=self.MESSAGE_PLACEHOLDER,
),
)
current_index += 2
else:
current_index += 1
async def _ensure_last_message_is_user(self):
"""Ensure that the last message in the chat history is a user message."""
if self.messages and self.messages[-1].role == AuthorRole.ASSISTANT:
self.messages.append(
ChatMessageContent(
role=AuthorRole.USER,
content=self.MESSAGE_PLACEHOLDER,
)
)
async def _parse_chat_history_to_session_state(self) -> dict[str, Any]:
"""Parse the chat history to a session state."""
session_state: dict[str, Any] = {"conversationHistory": {"messages": []}}
if len(self.messages) > 1:
# We don't take the last message as it needs to be sent separately in another parameter
for message in self.messages[:-1]:
if message.role not in [AuthorRole.USER, AuthorRole.ASSISTANT]:
logger.debug(f"Skipping message with unsupported role: {message}")
continue
session_state["conversationHistory"]["messages"].append({
"content": [{"text": message.content}],
"role": message.role.value,
})
return session_state
# endregion
@@ -0,0 +1,171 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from collections import deque
from collections.abc import AsyncIterable
from copy import deepcopy
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
from typing import TYPE_CHECKING, Any, ClassVar, Deque
from semantic_kernel.agents.channels.agent_channel import AgentChannel
from semantic_kernel.contents import ChatMessageContent
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.function_result_content import FunctionResultContent
from semantic_kernel.contents.image_content import ImageContent
from semantic_kernel.contents.streaming_text_content import StreamingTextContent
from semantic_kernel.contents.text_content import TextContent
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.agents.agent import Agent
from semantic_kernel.agents.chat_completion.chat_completion_agent import ChatHistoryAgentThread
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
@experimental
class ChatHistoryChannel(AgentChannel, ChatHistory):
"""An AgentChannel specialization for that acts upon a ChatHistoryHandler."""
thread: "ChatHistoryAgentThread"
ALLOWED_CONTENT_TYPES: ClassVar[tuple[type, ...]] = (
ImageContent,
FunctionCallContent,
FunctionResultContent,
StreamingTextContent,
TextContent,
)
@override
async def invoke(
self,
agent: "Agent",
**kwargs: Any,
) -> AsyncIterable[tuple[bool, ChatMessageContent]]:
"""Perform a discrete incremental interaction between a single Agent and AgentChat.
Args:
agent: The agent to interact with.
kwargs: The keyword arguments.
Returns:
An async iterable of ChatMessageContent.
"""
message_count = len(self.messages)
mutated_history = set()
message_queue: Deque[ChatMessageContent] = deque()
async for response in agent.invoke(
messages=self.messages[-1],
thread=self.thread,
):
# Capture all messages that have been included in the mutated history.
for message_index in range(message_count, len(self.messages)):
mutated_message = self.messages[message_index]
mutated_history.add(mutated_message)
message_queue.append(mutated_message)
# Update the message count pointer to reflect the current history.
message_count = len(self.messages)
# Avoid duplicating any message included in the mutated history and also returned by the enumeration result.
if response.message not in mutated_history:
self.messages.append(response.message)
message_queue.append(response.message)
# Dequeue the next message to yield.
yield_message = message_queue.popleft()
yield (
self._is_message_visible(message=yield_message, message_queue_count=len(message_queue)),
yield_message,
)
# Dequeue any remaining messages to yield.
while message_queue:
yield_message = message_queue.popleft()
yield (
self._is_message_visible(message=yield_message, message_queue_count=len(message_queue)),
yield_message,
)
@override
async def invoke_stream(
self, agent: "Agent", messages: list[ChatMessageContent], **kwargs: Any
) -> AsyncIterable["StreamingChatMessageContent"]:
"""Perform a discrete incremental stream interaction between a single Agent and AgentChat.
Args:
agent: The agent to interact with.
messages: The history of messages in the conversation.
kwargs: The keyword arguments
Returns:
An async iterable of ChatMessageContent.
"""
message_count = len(self.messages)
async for response_message in agent.invoke_stream(
messages=self.messages[-1],
thread=self.thread,
):
if response_message.message.content:
yield response_message.message
for message_index in range(message_count, len(self.messages)):
messages.append(self.messages[message_index])
def _is_message_visible(self, message: ChatMessageContent, message_queue_count: int) -> bool:
"""Determine if a message is visible to the user."""
return (
not any(isinstance(item, (FunctionCallContent, FunctionResultContent)) for item in message.items)
or message_queue_count == 0
)
@override
async def receive(
self,
history: list[ChatMessageContent],
) -> None:
"""Receive the conversation messages.
Do not include messages that only contain file references.
Args:
history: The history of messages in the conversation.
"""
filtered_history: list[ChatMessageContent] = []
for message in history:
new_message = deepcopy(message)
if new_message.items is None:
new_message.items = []
allowed_items = [item for item in new_message.items if isinstance(item, self.ALLOWED_CONTENT_TYPES)]
if not allowed_items:
continue
new_message.items.clear()
new_message.items.extend(allowed_items)
filtered_history.append(new_message)
self.messages.extend(filtered_history)
@override
async def get_history( # type: ignore
self,
) -> AsyncIterable[ChatMessageContent]:
"""Retrieve the message history specific to this channel.
Returns:
An async iterable of ChatMessageContent.
"""
for message in reversed(self.messages):
yield message
@override
async def reset(self) -> None:
"""Reset the channel state."""
self.messages.clear()
@@ -0,0 +1,121 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from collections.abc import AsyncIterable
from typing import TYPE_CHECKING, Any
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
from openai import AsyncOpenAI
from semantic_kernel.agents.channels.agent_channel import AgentChannel
from semantic_kernel.agents.open_ai.assistant_content_generation import create_chat_message, generate_message_content
from semantic_kernel.agents.open_ai.assistant_thread_actions import AssistantThreadActions
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.exceptions.agent_exceptions import AgentChatException
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.agents.agent import Agent
@experimental
class OpenAIAssistantChannel(AgentChannel):
"""OpenAI Assistant Channel."""
def __init__(self, client: AsyncOpenAI, thread_id: str) -> None:
"""Initialize the OpenAI Assistant Channel."""
self.client = client
self.thread_id = thread_id
@override
async def receive(self, history: list["ChatMessageContent"]) -> None:
"""Receive the conversation messages.
Args:
history: The conversation messages.
"""
for message in history:
if any(isinstance(item, FunctionCallContent) for item in message.items):
continue
await create_chat_message(self.client, self.thread_id, message)
@override
async def invoke(self, agent: "Agent", **kwargs: Any) -> AsyncIterable[tuple[bool, "ChatMessageContent"]]:
"""Invoke the agent.
Args:
agent: The agent to invoke.
kwargs: The keyword arguments.
Yields:
tuple[bool, ChatMessageContent]: The conversation messages.
"""
from semantic_kernel.agents.open_ai.openai_assistant_agent import OpenAIAssistantAgent
if not isinstance(agent, OpenAIAssistantAgent):
raise AgentChatException(f"Agent is not of the expected type {type(OpenAIAssistantAgent)}.")
async for is_visible, message in AssistantThreadActions.invoke(agent=agent, thread_id=self.thread_id, **kwargs):
yield is_visible, message
@override
async def invoke_stream(
self, agent: "Agent", messages: list[ChatMessageContent], **kwargs: Any
) -> AsyncIterable["ChatMessageContent"]:
"""Invoke the agent stream.
Args:
agent: The agent to invoke.
messages: The conversation messages.
kwargs: The keyword arguments.
Yields:
tuple[bool, StreamingChatMessageContent]: The conversation messages.
"""
from semantic_kernel.agents.open_ai.openai_assistant_agent import OpenAIAssistantAgent
if not isinstance(agent, OpenAIAssistantAgent):
raise AgentChatException(f"Agent is not of the expected type {type(OpenAIAssistantAgent)}.")
async for message in AssistantThreadActions.invoke_stream(
agent=agent, thread_id=self.thread_id, output_messages=messages, **kwargs
):
yield message
@override
async def get_history(self) -> AsyncIterable["ChatMessageContent"]:
"""Get the conversation history.
Yields:
ChatMessageContent: The conversation history.
"""
agent_names: dict[str, Any] = {}
thread_messages = await self.client.beta.threads.messages.list(
thread_id=self.thread_id, limit=100, order="desc"
)
for message in thread_messages.data:
assistant_name = None
if message.assistant_id and message.assistant_id not in agent_names:
agent = await self.client.beta.assistants.retrieve(message.assistant_id)
if agent.name:
agent_names[message.assistant_id] = agent.name
assistant_name = agent_names.get(message.assistant_id) if message.assistant_id else message.assistant_id
content: ChatMessageContent = generate_message_content(str(assistant_name), message)
if len(content.items) > 0:
yield content
@override
async def reset(self) -> None:
"""Reset the agent's thread."""
try:
await self.client.beta.threads.delete(thread_id=self.thread_id)
except Exception as e:
raise AgentChatException(f"Failed to delete thread: {e}")
@@ -0,0 +1,612 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import sys
import uuid
from collections.abc import AsyncGenerator, AsyncIterable, Awaitable, Callable
from typing import TYPE_CHECKING, Any, ClassVar
from pydantic import Field, model_validator
from semantic_kernel.agents import Agent, AgentResponseItem, AgentThread, DeclarativeSpecMixin, register_agent_type
from semantic_kernel.agents.channels.agent_channel import AgentChannel
from semantic_kernel.agents.channels.chat_history_channel import ChatHistoryChannel
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
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.function_call_content import FunctionCallContent
from semantic_kernel.contents.function_result_content import FunctionResultContent
from semantic_kernel.contents.history_reducer.chat_history_reducer import ChatHistoryReducer
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.exceptions import KernelServiceNotFoundError
from semantic_kernel.exceptions.agent_exceptions import (
AgentInitializationException,
AgentInvokeException,
AgentThreadOperationException,
)
from semantic_kernel.functions.kernel_arguments import KernelArguments
from semantic_kernel.functions.kernel_function import TEMPLATE_FORMAT_MAP
from semantic_kernel.functions.kernel_plugin import KernelPlugin
from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig
from semantic_kernel.utils.telemetry.agent_diagnostics.decorators import (
trace_agent_get_response,
trace_agent_invocation,
trace_agent_streaming_invocation,
)
if TYPE_CHECKING:
from semantic_kernel.kernel import Kernel
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__)
class ChatHistoryAgentThread(AgentThread):
"""Chat History Agent Thread class."""
def __init__(self, chat_history: ChatHistory | None = None, thread_id: str | None = None) -> None:
"""Initialize the ChatCompletionAgent Thread.
Args:
chat_history: The chat history for the thread. If None, a new ChatHistory instance will be created.
thread_id: The ID of the thread. If None, a new thread will be created.
"""
super().__init__()
self._chat_history = chat_history if chat_history is not None else ChatHistory()
self._id: str = thread_id or f"thread_{uuid.uuid4().hex}"
self._is_deleted = False
def __len__(self) -> int:
"""Returns the length of the chat history."""
return len(self._chat_history)
@override
async def _create(self) -> str:
"""Starts the thread and returns its ID."""
return self._id
@override
async def _delete(self) -> None:
"""Ends the current thread."""
self._chat_history.clear()
@override
async def _on_new_message(self, new_message: str | ChatMessageContent) -> None:
"""Called when a new message has been contributed to the chat."""
if isinstance(new_message, str):
new_message = ChatMessageContent(role=AuthorRole.USER, content=new_message)
if (
not new_message.metadata
or "thread_id" not in new_message.metadata
or new_message.metadata["thread_id"] != self._id
):
self._chat_history.add_message(new_message)
async def get_messages(self) -> AsyncIterable[ChatMessageContent]:
"""Retrieve the current chat history.
Returns:
An async iterable of ChatMessageContent.
"""
if self._is_deleted:
raise AgentThreadOperationException("Cannot retrieve chat history, since the thread has been deleted.")
if self._id is None:
await self.create()
for message in self._chat_history.messages:
yield message
async def reduce(self) -> ChatHistory | None:
"""Reduce the chat history to a smaller size."""
if self._id is None:
raise AgentThreadOperationException("Cannot reduce chat history, since the thread is not currently active.")
if not isinstance(self._chat_history, ChatHistoryReducer):
return None
return await self._chat_history.reduce()
@register_agent_type("chat_completion_agent")
class ChatCompletionAgent(DeclarativeSpecMixin, Agent):
"""A Chat Completion Agent based on ChatCompletionClientBase."""
function_choice_behavior: FunctionChoiceBehavior | None = Field(
default_factory=lambda: FunctionChoiceBehavior.Auto()
)
channel_type: ClassVar[type[AgentChannel] | None] = ChatHistoryChannel
service: ChatCompletionClientBase | None = Field(default=None, exclude=True)
def __init__(
self,
*,
arguments: KernelArguments | None = None,
description: str | None = None,
function_choice_behavior: FunctionChoiceBehavior | None = None,
id: str | None = None,
instructions: str | None = None,
kernel: "Kernel | None" = None,
name: str | None = None,
plugins: list[KernelPlugin | object] | dict[str, KernelPlugin | object] | None = None,
prompt_template_config: PromptTemplateConfig | None = None,
service: ChatCompletionClientBase | None = None,
) -> None:
"""Initialize a new instance of ChatCompletionAgent.
Args:
arguments: The kernel arguments for the agent. Invoke method arguments take precedence over
the arguments provided here.
description: The description of the agent.
function_choice_behavior: The function choice behavior to determine how and which plugins are
advertised to the model.
kernel: The kernel instance. If both a kernel and a service are provided, the service will take precedence
if they share the same service_id or ai_model_id. Otherwise if separate, the first AI service
registered on the kernel will be used.
id: The unique identifier for the agent. If not provided,
a unique GUID will be generated.
instructions: The instructions for the agent.
name: The name of the agent.
plugins: The plugins for the agent. If plugins are included along with a kernel, any plugins
that already exist in the kernel will be overwritten.
prompt_template_config: The prompt template configuration for the agent.
service: The chat completion service instance. If a kernel is provided with the same service_id or
`ai_model_id`, the service will take precedence.
"""
args: dict[str, Any] = {
"description": description,
}
if name is not None:
args["name"] = name
if id is not None:
args["id"] = id
if kernel is not None:
args["kernel"] = kernel
if arguments is not None:
args["arguments"] = arguments
if instructions and prompt_template_config and instructions != prompt_template_config.template:
logger.info(
f"Both `instructions` ({instructions}) and `prompt_template_config` "
f"({prompt_template_config.template}) were provided. Using template in `prompt_template_config` "
"and ignoring `instructions`."
)
if plugins is not None:
args["plugins"] = plugins
if function_choice_behavior is not None:
args["function_choice_behavior"] = function_choice_behavior
if service is not None:
args["service"] = service
if instructions is not None:
args["instructions"] = instructions
if prompt_template_config is not None:
args["prompt_template"] = TEMPLATE_FORMAT_MAP[prompt_template_config.template_format](
prompt_template_config=prompt_template_config
)
if prompt_template_config.template is not None:
# Use the template from the prompt_template_config if it is provided
args["instructions"] = prompt_template_config.template
super().__init__(**args)
@model_validator(mode="after")
def configure_service(self) -> "ChatCompletionAgent":
"""Configure the service used by the ChatCompletionAgent."""
if self.service is None:
return self
if not isinstance(self.service, ChatCompletionClientBase):
raise AgentInitializationException(
f"Service provided for ChatCompletionAgent is not an instance of ChatCompletionClientBase. "
f"Service: {type(self.service)}"
)
self.kernel.add_service(self.service, overwrite=True)
return self
async def create_channel(
self, chat_history: ChatHistory | None = None, thread_id: str | None = None
) -> AgentChannel:
"""Create a ChatHistoryChannel.
Args:
chat_history: The chat history for the channel. If None, a new ChatHistory instance will be created.
thread_id: The ID of the thread. If None, a new thread will be created.
Returns:
An instance of AgentChannel.
"""
from semantic_kernel.agents.chat_completion.chat_completion_agent import ChatHistoryAgentThread
ChatHistoryChannel.model_rebuild()
thread = ChatHistoryAgentThread(chat_history=chat_history, thread_id=thread_id)
if thread.id is None:
await thread.create()
messages = [message async for message in thread.get_messages()]
return ChatHistoryChannel(messages=messages, thread=thread)
# region Declarative Spec
@override
@classmethod
async def _from_dict(
cls,
data: dict,
*,
kernel: "Kernel | None" = None,
plugins: list[KernelPlugin | object] | dict[str, KernelPlugin | object] | None = None,
**kwargs,
) -> "ChatCompletionAgent":
# Returns the normalized spec fields and a kernel configured with plugins, if present.
fields, kernel = cls._normalize_spec_fields(data, kernel=kernel, plugins=plugins, **kwargs)
if "service" in kwargs:
fields["service"] = kwargs["service"]
if "function_choice_behavior" in kwargs:
fields["function_choice_behavior"] = kwargs["function_choice_behavior"]
# Handle arguments from kwargs, merging with any arguments from _normalize_spec_fields
if "arguments" in kwargs and kwargs["arguments"] is not None:
incoming_args = kwargs["arguments"]
if fields.get("arguments") is not None:
# Use KernelArguments' built-in merge operator, with incoming_args taking precedence
fields["arguments"] = fields["arguments"] | incoming_args
else:
fields["arguments"] = incoming_args
return cls(**fields, kernel=kernel)
# endregion
# region Invocation Methods
@trace_agent_get_response
@override
async def get_response(
self,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
*,
thread: AgentThread | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs: Any,
) -> AgentResponseItem[ChatMessageContent]:
"""Get a response from the agent.
Args:
messages: The input chat message content either as a string, ChatMessageContent or
a list of strings or ChatMessageContent.
thread: The thread to use for agent invocation.
arguments: The kernel arguments.
kernel: The kernel instance.
kwargs: The keyword arguments.
Returns:
An AgentResponseItem of type ChatMessageContent.
"""
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: ChatHistoryAgentThread(),
expected_type=ChatHistoryAgentThread,
)
assert thread.id is not None # nosec
chat_history = ChatHistory()
async for message in thread.get_messages():
chat_history.add_message(message)
responses: list[ChatMessageContent] = []
async for response in self._inner_invoke(
thread,
chat_history,
None,
arguments,
kernel,
**kwargs,
):
responses.append(response)
if not responses:
raise AgentInvokeException("No response from agent.")
return AgentResponseItem(message=responses[-1], thread=thread)
@trace_agent_invocation
@override
async def invoke(
self,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
*,
thread: AgentThread | None = None,
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseItem[ChatMessageContent]]:
"""Invoke the chat history handler.
Args:
messages: The input chat message content either as a string, ChatMessageContent or
a list of strings or ChatMessageContent.
thread: The thread to use for agent invocation.
on_intermediate_message: A callback function to handle intermediate steps of the agent's execution.
arguments: The kernel arguments.
kernel: The kernel instance.
kwargs: The keyword arguments.
Returns:
An async iterable of AgentResponseItem of type ChatMessageContent.
"""
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: ChatHistoryAgentThread(),
expected_type=ChatHistoryAgentThread,
)
assert thread.id is not None # nosec
chat_history = ChatHistory()
async for message in thread.get_messages():
chat_history.add_message(message)
async for response in self._inner_invoke(
thread,
chat_history,
on_intermediate_message,
arguments,
kernel,
**kwargs,
):
yield AgentResponseItem(message=response, thread=thread)
@trace_agent_streaming_invocation
@override
async def invoke_stream(
self,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
*,
thread: AgentThread | None = None,
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseItem[StreamingChatMessageContent]]:
"""Invoke the chat history handler in streaming mode.
Args:
messages: The chat message content either as a string, ChatMessageContent or
a list of str or ChatMessageContent.
thread: The thread to use for agent invocation.
on_intermediate_message: A callback function to handle intermediate steps of the
agent's execution as fully formed messages.
arguments: The kernel arguments.
kernel: The kernel instance.
kwargs: The keyword arguments.
Returns:
An async generator of AgentResponseItem of type StreamingChatMessageContent.
"""
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: ChatHistoryAgentThread(),
expected_type=ChatHistoryAgentThread,
)
assert thread.id is not None # nosec
chat_history = ChatHistory()
async for message in thread.get_messages():
chat_history.add_message(message)
if arguments is None:
arguments = KernelArguments(**kwargs)
else:
arguments.update(kwargs)
kernel = kernel or self.kernel
arguments = self._merge_arguments(arguments)
chat_completion_service, settings = await self._get_chat_completion_service_and_settings(
kernel=kernel, arguments=arguments
)
# If the user hasn't provided a function choice behavior, use the agent's default.
if settings.function_choice_behavior is None:
settings.function_choice_behavior = self.function_choice_behavior
agent_chat_history = await self._prepare_agent_chat_history(
history=chat_history,
kernel=kernel,
arguments=arguments,
)
message_count_before_completion = len(agent_chat_history)
logger.debug(f"[{type(self).__name__}] Invoking {type(chat_completion_service).__name__}.")
responses: AsyncGenerator[list[StreamingChatMessageContent], Any] = (
chat_completion_service.get_streaming_chat_message_contents(
chat_history=agent_chat_history,
settings=settings,
kernel=kernel,
arguments=arguments,
)
)
logger.debug(
f"[{type(self).__name__}] Invoked {type(chat_completion_service).__name__} "
f"with message count: {message_count_before_completion}."
)
role = None
response_builder: list[str] = []
start_idx = len(agent_chat_history)
async for response_list in responses:
for response in response_list:
role = response.role
response.name = self.name
response_builder.append(response.content)
if (
role == AuthorRole.ASSISTANT
and (response.items or response.metadata.get("usage"))
and not any(
isinstance(item, (FunctionCallContent, FunctionResultContent)) for item in response.items
)
):
yield AgentResponseItem(message=response, thread=thread)
# Drain newly added tool messages since last index to maintain
# correct order and avoid duplicates
new_messages = await self._drain_mutated_messages(
agent_chat_history,
start_idx,
thread,
)
# resets start_idx to the latest length of agent_chat_history.
start_idx = len(agent_chat_history)
if on_intermediate_message:
for message in new_messages:
await on_intermediate_message(message)
if role != AuthorRole.TOOL:
# Tool messages will be automatically added to the chat history by the auto function invocation loop
# if it's the response (i.e. terminated by a filter), thus we need to avoid notifying the thread about
# them multiple times.
await thread.on_new_message(
ChatMessageContent(
role=role if role else AuthorRole.ASSISTANT, content="".join(response_builder), name=self.name
)
)
# endregion
# region Helper Methods
async def _inner_invoke(
self,
thread: ChatHistoryAgentThread,
history: ChatHistory,
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs: Any,
) -> AsyncIterable[ChatMessageContent]:
"""Helper method to invoke the agent with a chat history in non-streaming mode."""
if arguments is None:
arguments = KernelArguments(**kwargs)
else:
arguments.update(kwargs)
kernel = kernel or self.kernel
arguments = self._merge_arguments(arguments)
chat_completion_service, settings = await self._get_chat_completion_service_and_settings(
kernel=kernel, arguments=arguments
)
# If the user hasn't provided a function choice behavior, use the agent's default.
if settings.function_choice_behavior is None:
settings.function_choice_behavior = self.function_choice_behavior
agent_chat_history = await self._prepare_agent_chat_history(
history=history,
kernel=kernel,
arguments=arguments,
)
start_idx = len(agent_chat_history)
message_count_before_completion = len(agent_chat_history)
logger.debug(f"[{type(self).__name__}] Invoking {type(chat_completion_service).__name__}.")
responses = await chat_completion_service.get_chat_message_contents(
chat_history=agent_chat_history,
settings=settings,
kernel=kernel,
arguments=arguments,
)
logger.debug(
f"[{type(self).__name__}] Invoked {type(chat_completion_service).__name__} "
f"with message count: {message_count_before_completion}."
)
# Drain newly added tool messages since last index to maintain
# correct order and avoid duplicates
new_msgs = await self._drain_mutated_messages(
agent_chat_history,
start_idx,
thread,
)
if on_intermediate_message:
for msg in new_msgs:
await on_intermediate_message(msg)
for response in responses:
response.name = self.name
if response.role != AuthorRole.TOOL:
# Tool messages will be automatically added to the chat history by the auto function invocation loop
# if it's the response (i.e. terminated by a filter),, thus we need to avoid notifying the thread about
# them multiple times.
await thread.on_new_message(response)
yield response
async def _prepare_agent_chat_history(
self, history: ChatHistory, kernel: "Kernel", arguments: KernelArguments
) -> ChatHistory:
"""Prepare the agent chat history from the input history by adding the formatted instructions."""
formatted_instructions = await self.format_instructions(kernel, arguments)
messages = []
if formatted_instructions:
messages.append(ChatMessageContent(role=AuthorRole.SYSTEM, content=formatted_instructions, name=self.name))
if history.messages:
messages.extend(history.messages)
return ChatHistory(messages=messages)
async def _get_chat_completion_service_and_settings(
self, kernel: "Kernel", arguments: KernelArguments
) -> tuple[ChatCompletionClientBase, PromptExecutionSettings]:
"""Get the chat completion service and settings."""
chat_completion_service, settings = kernel.select_ai_service(arguments=arguments, type=ChatCompletionClientBase)
if not chat_completion_service:
raise KernelServiceNotFoundError(
"Chat completion service not found. Check your service or kernel configuration."
)
assert isinstance(chat_completion_service, ChatCompletionClientBase) # nosec
assert settings is not None # nosec
return chat_completion_service, settings
async def _drain_mutated_messages(
self,
history: ChatHistory,
start: int,
thread: ChatHistoryAgentThread,
) -> list[ChatMessageContent]:
"""Return messages appended to history after start and push them to thread."""
drained: list[ChatMessageContent] = []
for i in range(start, len(history)):
msg: ChatMessageContent = history[i] # type: ignore
msg.name = self.name
await thread.on_new_message(msg)
drained.append(msg)
return drained
@@ -0,0 +1,660 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import sys
from collections.abc import AsyncIterable, Awaitable, Callable, Sequence
from os import environ, path
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Literal
from microsoft_agents.activity import ActivityTypes
from microsoft_agents.copilotstudio.client import AgentType, CopilotClient, PowerPlatformCloud
from msal import ConfidentialClientApplication, PublicClientApplication
from msal_extensions import FilePersistence, PersistedTokenCache, build_encrypted_persistence
from pydantic import ValidationError
from semantic_kernel.agents import Agent
from semantic_kernel.agents.agent import AgentResponseItem, AgentThread
from semantic_kernel.agents.channels.agent_channel import AgentChannel
from semantic_kernel.agents.copilot_studio.copilot_studio_agent_settings import (
CopilotStudioAgentAuthMode,
CopilotStudioAgentSettings,
)
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.exceptions.agent_exceptions import (
AgentInitializationException,
AgentThreadInitializationException,
AgentThreadOperationException,
)
from semantic_kernel.functions import KernelArguments
from semantic_kernel.functions.kernel_function import TEMPLATE_FORMAT_MAP
from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig
from semantic_kernel.utils.feature_stage_decorator import experimental
from semantic_kernel.utils.naming import generate_random_ascii_name
from semantic_kernel.utils.telemetry.agent_diagnostics.decorators import (
trace_agent_get_response,
trace_agent_invocation,
trace_agent_streaming_invocation,
)
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else: # pragma: no cover
from typing_extensions import override
if TYPE_CHECKING: # pragma: no cover
from semantic_kernel.kernel import Kernel
logger: logging.Logger = logging.getLogger(__name__)
# region Token Factory
@experimental
def _log_auth_failure(result: dict[str, Any]) -> None:
category = result.get("error", "unknown_error")
corr_id = result.get("correlation_id", "n/a")[:8] # Only log the first 8 characters
logger.error(f"Copilot auth failure, category={category}, corr_id={corr_id}")
@experimental
class _CopilotStudioAgentTokenFactory:
"""A CopilotStudioAgentTokenFactory to handle authentication for the Copilot Studio agent."""
def __init__(
self,
*,
settings: CopilotStudioAgentSettings,
cache_path: str,
mode: CopilotStudioAgentAuthMode,
client_secret: str | None = None,
client_certificate: Path | None = None,
user_assertion: str | None = None,
scopes: Sequence[str] | None = None,
) -> None:
self.settings = settings
self.cache = self._get_msal_token_cache(cache_path)
self.mode = mode
self.client_secret = client_secret
self.client_cert_path = client_certificate
self.user_assertion = user_assertion
self.scopes = scopes or ["https://api.powerplatform.com/.default"]
@staticmethod
def _get_msal_token_cache(cache_path: str, fallback_to_plaintext=True) -> PersistedTokenCache:
"""Get the MSAL token cache."""
persistence = None
# Note: This stores both encrypted persistence and plaintext persistence
# into same location, therefore their data would likely override with each other.
try:
persistence = build_encrypted_persistence(cache_path)
except Exception: # pylint: disable=bare-except
# On Linux, encryption exception will be raised during initialization.
# On Windows and macOS, they won't be detected here,
# but will be raised during their load() or save().
if not fallback_to_plaintext:
raise
logging.warning("Encryption unavailable. Opting in to plain text.")
persistence = FilePersistence(cache_path)
return PersistedTokenCache(persistence)
def acquire(self) -> str:
"""Return a valid bearer token or raise AgentInitializationException."""
if self.mode is CopilotStudioAgentAuthMode.SERVICE:
# SERVICE auth wiring is present but not yet supported end-to-end.
logger.warning("SERVICE authentication mode is not yet supported; falling back to error.")
raise AgentInitializationException(
"Copilot Studio SERVICE authentication is not available yet. Please use INTERACTIVE mode instead."
)
match self.mode:
case CopilotStudioAgentAuthMode.SERVICE:
return self._acquire_service_token() # unreachable until the guard is removed
case _:
return self._acquire_interactive_token()
def _new_confidential_client(self, **extra_kwargs) -> ConfidentialClientApplication:
return ConfidentialClientApplication(
client_id=self.settings.app_client_id,
authority=f"https://login.microsoftonline.com/{self.settings.tenant_id}",
token_cache=self.cache,
**extra_kwargs,
)
def _acquire_service_token(self) -> str:
if not self.client_secret and not self.client_cert_path:
raise AgentInitializationException(
"client_secret *or* client_certificate is required for service-to-service auth."
)
kwargs: dict[str, Any] = {}
if self.client_secret:
kwargs["client_credential"] = self.client_secret
else: # certificate
if not self.client_cert_path:
raise AgentInitializationException(
"If no client_secret is provided, a client_certificate is required for service-to-service auth."
)
kwargs["client_credential"] = {
"private_key": Path(self.client_cert_path).read_text(),
"thumbprint": self._cert_thumbprint(self.client_cert_path),
}
app = self._new_confidential_client(**kwargs)
# proactive caching
result = app.acquire_token_silent(self.scopes, account=None) or app.acquire_token_for_client(scopes=self.scopes)
return self._unwrap(result)
# interactive
def _acquire_interactive_token(self) -> str:
app = PublicClientApplication(
self.settings.app_client_id,
authority=f"https://login.microsoftonline.com/{self.settings.tenant_id}",
token_cache=self.cache,
)
accounts = app.get_accounts()
result = (
app.acquire_token_silent(self.scopes, account=accounts[0])
if accounts
else app.acquire_token_interactive(self.scopes)
)
return self._unwrap(result)
@staticmethod
def _unwrap(result: dict[str, Any]) -> str:
if "access_token" in result:
return result["access_token"]
_log_auth_failure(result)
raise AgentInitializationException("Authentication failed; see logs for category and correlation code.")
@staticmethod
def _cert_thumbprint(cert_path: Path) -> str:
import hashlib
import ssl
pem_bytes = Path(cert_path).read_bytes()
der_bytes = ssl.PEM_cert_to_DER_cert(pem_bytes.decode())
return hashlib.sha1(der_bytes, usedforsecurity=False).hexdigest().upper()
# endregion
# region CopilotStudioAgentThread
@experimental
class CopilotStudioAgentThread(AgentThread):
"""The Copilot Studio Agent Thread."""
def __init__(
self,
client: CopilotClient,
conversation_id: str | None = None,
) -> None:
"""Initializes a new instance of the CopilotStudioAgentThread class.
Args:
client: The Copilot Client.
conversation_id: The conversation ID. This is the Copilot Studio conversation ID.
"""
super().__init__()
if client is None:
raise AgentThreadInitializationException("CopilotClient cannot be None")
self._client = client
self._conversation_id = conversation_id # Copilot Studio conversation ID
@property
def conversation_id(self) -> str | None:
"""Get the conversation ID."""
return self._conversation_id
@conversation_id.setter
def conversation_id(self, value: str | None) -> None:
"""Set the conversation ID."""
self._conversation_id = value
@override
@property
def id(self) -> str | None:
"""Get the thread ID."""
return self.conversation_id
@override
async def _create(self) -> str:
# Creation is deferred to CopilotStudioAgent._ensure_conversation.
if self._is_deleted:
raise AgentThreadOperationException("Cannot create a thread that has been deleted.")
return ""
@override
async def _delete(self) -> None:
if self._is_deleted:
return
if self.conversation_id is None:
raise AgentThreadOperationException("Cannot delete the thread, since it has not been created.")
self._conversation_id = None
self._is_deleted = True
@override
async def _on_new_message(self, new_message: ChatMessageContent) -> None:
raise NotImplementedError(
"This method is not implemented for CopilotStudioAgent. "
"Messages and responses are automatically handled by the Copilot Agent."
)
@experimental
class CopilotStudioAgent(Agent):
"""Semantic Kernel abstraction over a Copilot Studio Agent."""
client: CopilotClient
channel_type: ClassVar[type[AgentChannel] | None] = None
def __init__(
self,
*,
client: CopilotClient | None = None,
arguments: KernelArguments | None = None,
description: str | None = None,
id: str | None = None,
instructions: str | None = None,
kernel: "Kernel | None" = None,
name: str | None = None,
prompt_template_config: PromptTemplateConfig | None = None,
) -> None:
"""Initializes a new instance of the CopilotStudioAgent class.
Args:
client: The Copilot Client
arguments: The Kernel Arguments to use at the agent-level.
description: The description of the agent.
id: The unique identifier for the agent. If not provided,
a unique GUID will be generated.
instructions: The instructions for the agent.
kernel: The kernel instance.
name: The name of the agent.
prompt_template_config: The prompt template configuration for the agent.
"""
if client is None:
client = self.create_client()
args: dict[str, Any] = {
"client": client,
"name": name or f"copilot_agent_{generate_random_ascii_name(6)}",
"description": description,
}
if id is not None:
args["id"] = id
if instructions is not None:
args["instructions"] = instructions
if kernel is not None:
args["kernel"] = kernel
if arguments is not None:
args["arguments"] = arguments
if instructions and prompt_template_config and instructions != prompt_template_config.template:
logger.info(
"Both `instructions` and `prompt_template_config` supplied "
"using the template inside `prompt_template_config`."
)
if prompt_template_config:
args["prompt_template"] = TEMPLATE_FORMAT_MAP[prompt_template_config.template_format](
prompt_template_config=prompt_template_config
)
# Overrides raw instructions if template contains the final string
if prompt_template_config.template:
args["instructions"] = prompt_template_config.template
super().__init__(**args)
def model_post_init(self, __ctx: Any) -> None:
"""Post-initialization hook for the model."""
super().model_post_init(__ctx)
if self.kernel.plugins:
logger.warning("Plugins are not supported by CopilotStudioAgent; any kernel plugins will be ignored.")
@staticmethod
def create_client(
*,
auth_mode: CopilotStudioAgentAuthMode | Literal["interactive", "service"] | None = None,
agent_identifier: str | None = None,
app_client_id: str | None = None,
client_secret: str | None = None,
client_certificate: str | None = None,
cloud: PowerPlatformCloud | None = None,
copilot_agent_type: AgentType | None = None,
custom_power_platform_cloud: str | None = None,
env_file_encoding: str | None = None,
env_file_path: str | None = None,
environment_id: str | None = None,
tenant_id: str | None = None,
user_assertion: str | None = None,
) -> CopilotClient:
"""Create the Copilot Studio Agent Client.
Args:
auth_mode: The authentication mode. This can be either `interactive` or `service`.
agent_identifier: The agent identifier. This is the `Schema Name` of the agent from the
Copilot Studio Advanced Metadata settings.
app_client_id: The app client ID. This is the app ID of the app registration configured in Entra.
client_secret: The client secret. This is the secret of the app registration.
client_certificate: The client certificate. This is the certificate of the app registration.
cloud: The cloud environment.
copilot_agent_type: The type of Copilot agent.
custom_power_platform_cloud: The custom Power Platform cloud.
env_file_path: The path to the environment file.
env_file_encoding: The encoding of the environment file.
environment_id: The environment ID. This is from the Copilot Studio Advanced Metadata settings.
tenant_id: The tenant ID. This is the tenant ID related to the app registration.
user_assertion: The user assertion. This is the token used for on-behalf-of authentication.
Returns:
CopilotClient: The Copilot client.
"""
if auth_mode is not None and isinstance(auth_mode, str):
auth_mode = CopilotStudioAgentAuthMode(auth_mode)
try:
connection_settings = CopilotStudioAgentSettings(
app_client_id=app_client_id,
tenant_id=tenant_id,
environment_id=environment_id,
agent_identifier=agent_identifier,
cloud=cloud,
type=copilot_agent_type,
custom_power_platform_cloud=custom_power_platform_cloud,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
client_secret=client_secret,
client_certificate=client_certificate,
user_assertion=user_assertion,
auth_mode=auth_mode,
)
except ValidationError as exc:
raise AgentInitializationException(f"Failed to create Copilot Studio Agent settings: {exc}") from exc
missing_params = [name for name in ("app_client_id", "tenant_id") if not getattr(connection_settings, name)]
if missing_params:
raise AgentInitializationException(f"Missing required configuration field(s): {', '.join(missing_params)}")
cache_file = environ.get("TOKEN_CACHE_PATH_INTERACTIVE") or path.join(
path.dirname(__file__), "bin", "token_cache_interactive.bin"
)
token = _CopilotStudioAgentTokenFactory(
settings=connection_settings,
cache_path=cache_file,
mode=connection_settings.auth_mode,
client_secret=connection_settings.client_secret.get_secret_value()
if connection_settings.client_secret
else None,
client_certificate=Path(client_certificate) if client_certificate else None,
user_assertion=user_assertion,
).acquire()
return CopilotClient(connection_settings, token)
# region Invocation Methods
@trace_agent_get_response
@override
async def get_response(
self,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
*,
thread: AgentThread | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs,
) -> AgentResponseItem[ChatMessageContent]:
"""Get a response from the agent.
Args:
messages: The messages to send to the agent.
thread: The thread to use for the agent.
arguments: The arguments to pass to the agent. These take precedence over the agent-defined args.
kernel: The kernel to use for the agent. This kernel takes precedence over the agent-defined kernel.
**kwargs: Additional keyword arguments.
Returns:
A chat message content and thread with the response.
"""
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: CopilotStudioAgentThread(self.client),
expected_type=CopilotStudioAgentThread,
)
if not isinstance(thread, CopilotStudioAgentThread):
raise AgentThreadOperationException("The thread is not a Copilot Studio Agent thread.")
normalized_messages = self._normalize_messages(messages)
responses: list[ChatMessageContent] = []
async for response in self._inner_invoke(
thread=thread,
messages=normalized_messages,
on_intermediate_message=None,
arguments=arguments,
kernel=kernel,
**kwargs,
):
responses.append(response)
return AgentResponseItem(message=responses[-1], thread=thread)
@trace_agent_invocation
@override
async def invoke(
self,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
*,
thread: AgentThread | None = None,
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs,
) -> AsyncIterable[AgentResponseItem[ChatMessageContent]]:
"""Invoke the agent.
Args:
messages: The messages to send to the agent.
thread: The thread to use for the agent.
on_intermediate_message: A callback function to call with each intermediate message.
arguments: The arguments to pass to the agent.
kernel: The kernel to use for the agent.
**kwargs: Additional keyword arguments.
Yields:
A chat message content and thread with the response.
"""
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: CopilotStudioAgentThread(self.client),
expected_type=CopilotStudioAgentThread,
)
if not isinstance(thread, CopilotStudioAgentThread):
raise AgentThreadOperationException("The thread is not a Copilot Studio Agent thread.")
normalized_messages = self._normalize_messages(messages)
async for response in self._inner_invoke(
thread=thread,
messages=normalized_messages,
on_intermediate_message=on_intermediate_message,
arguments=arguments,
kernel=kernel,
**kwargs,
):
yield AgentResponseItem(message=response, thread=thread)
@trace_agent_streaming_invocation
@override
async def invoke_stream(
self,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
*,
thread: AgentThread | None = None,
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseItem[StreamingChatMessageContent]]:
"""Invoke the agent and stream the response.
Note: this is a “pseudo-streaming” implementation.
We're internally delegating to the real async generator `_inner_invoke`.
Each complete ChatMessageContent is wrapped in exactly one
StreamingChatMessageContent chunk, so downstream consumers can iterate
without change. The stream yields at least once; callers still receive
on_intermediate callbacks in real time.
Args:
messages: The messages to send to the agent.
thread: The thread to use for the agent.
on_intermediate_message: A callback function to call with each intermediate message.
arguments: The arguments to pass to the agent.
kernel: The kernel to use for the agent.
**kwargs: Additional keyword arguments.
Yields:
A chat message content and thread with the response.
"""
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: CopilotStudioAgentThread(self.client),
expected_type=CopilotStudioAgentThread,
)
if not isinstance(thread, CopilotStudioAgentThread):
raise AgentThreadOperationException("The thread is not a Copilot Studio Agent thread.")
normalized_messages = self._normalize_messages(messages)
responses: list[ChatMessageContent] = []
async for resp in self._inner_invoke(
thread=thread,
messages=normalized_messages,
on_intermediate_message=on_intermediate_message,
arguments=arguments,
kernel=kernel,
**kwargs,
):
responses.append(resp)
for i, resp in enumerate(responses):
stream_msg = self._to_streaming(resp, index=i)
yield AgentResponseItem(message=stream_msg, thread=thread)
# endregion
# region Helper Methods
async def _inner_invoke(
self,
thread: CopilotStudioAgentThread,
messages: list[str] | None = None,
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs: Any,
) -> AsyncIterable[ChatMessageContent]:
if arguments is None:
arguments = KernelArguments(**kwargs)
else:
arguments.update(kwargs)
kernel = kernel or self.kernel
arguments = self._merge_arguments(arguments)
prompt_parts: list[str] = []
formatted_instructions = await self.format_instructions(kernel, arguments)
if formatted_instructions:
prompt_parts.append(formatted_instructions)
if messages:
prompt_parts.extend(messages)
final_prompt: str = "\n".join(prompt_parts).strip()
await self._ensure_conversation(thread)
async for activity in self.client.ask_question(question=final_prompt, conversation_id=thread.id):
if activity.type == ActivityTypes.message:
if (
activity.text_format == "markdown"
and activity.suggested_actions
and activity.suggested_actions.actions
):
for action in activity.suggested_actions.actions:
if on_intermediate_message:
await on_intermediate_message(
ChatMessageContent(role=AuthorRole.ASSISTANT, name=self.name, content=action.text)
)
yield ChatMessageContent(role=AuthorRole.ASSISTANT, name=self.name, content=activity.text)
async def _ensure_conversation(self, thread: CopilotStudioAgentThread) -> None:
"""Guarantee that `thread.conversation_id` is populated."""
if thread.id:
return
async for act in self.client.start_conversation():
conversation_id = getattr(getattr(act, "conversation", None), "id", None)
if conversation_id:
thread.conversation_id = conversation_id
return
# If we reach this point, the service misbehaved, so throw
raise AgentThreadOperationException("Copilot Studio did not return a conversation ID.")
@staticmethod
def _normalize_messages(messages: str | ChatMessageContent | list[str | ChatMessageContent] | None) -> list[str]:
"""Return a flat list[str] irrespective of the caller-supplied type."""
if messages is None:
return []
if isinstance(messages, (str, ChatMessageContent)):
messages = [messages]
normalized: list[str] = []
for m in messages:
normalized.append(m.content if isinstance(m, ChatMessageContent) else str(m))
return normalized
@staticmethod
def _to_streaming(
msg: ChatMessageContent,
*,
index: int,
) -> StreamingChatMessageContent:
"""Wrap a complete ChatMessageContent in a StreamingChatMessageContent."""
return StreamingChatMessageContent(
role=msg.role,
name=msg.name,
content=msg.content,
choice_index=index,
metadata=msg.metadata,
)
@override
async def _notify_thread_of_new_message(self, thread, new_message):
"""Copilot Studio Agent doesn't need to notify the thread of new messages.
The new message is passed to the agent when invoking the agent.
"""
pass
# endregion
@@ -0,0 +1,40 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
from typing import ClassVar
from microsoft_agents.copilotstudio.client import (
AgentType,
PowerPlatformCloud,
)
from pydantic import Field, SecretStr
from semantic_kernel.kernel_pydantic import KernelBaseSettings
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class CopilotStudioAgentAuthMode(str, Enum):
"""The Copilot Studio agent authentication mode."""
INTERACTIVE = "interactive" # user authentication
SERVICE = "service" # client-credentials (app secret/cert)
@experimental
class CopilotStudioAgentSettings(KernelBaseSettings):
"""Copilot Studio Agent settings currently used by the CopilotStudioAgent."""
env_prefix: ClassVar[str] = "COPILOT_STUDIO_AGENT_"
app_client_id: str | None = None
tenant_id: str | None = None
environment_id: str | None = None
agent_identifier: str | None = None
cloud: PowerPlatformCloud = Field(default=PowerPlatformCloud.UNKNOWN)
copilot_agent_type: AgentType = Field(default=AgentType.PUBLISHED)
custom_power_platform_cloud: str | None = None
client_secret: SecretStr | None = None
client_certificate: str | None = None
user_assertion: str | None = None
auth_mode: CopilotStudioAgentAuthMode = Field(default=CopilotStudioAgentAuthMode.INTERACTIVE)
@@ -0,0 +1,197 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import threading
from collections.abc import AsyncIterable
from pydantic import Field, PrivateAttr
from semantic_kernel.agents import Agent
from semantic_kernel.agents.channels.agent_channel import AgentChannel
from semantic_kernel.agents.group_chat.agent_chat_utils import KeyEncoder
from semantic_kernel.agents.group_chat.broadcast_queue import BroadcastQueue, ChannelReference
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.exceptions.agent_exceptions import AgentChatException
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
logger: logging.Logger = logging.getLogger(__name__)
@experimental
class AgentChat(KernelBaseModel):
"""A base class chat interface for agents."""
broadcast_queue: BroadcastQueue = Field(default_factory=BroadcastQueue)
agent_channels: dict[str, AgentChannel] = Field(default_factory=dict)
channel_map: dict[Agent, str] = Field(default_factory=dict)
history: ChatHistory = Field(default_factory=ChatHistory)
_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
_is_active: bool = False
@property
def is_active(self) -> bool:
"""Indicates whether the agent is currently active."""
return self._is_active
def set_activity_or_throw(self):
"""Set the activity signal or throw an exception if another agent is active."""
with self._lock:
if self._is_active:
raise Exception("Unable to proceed while another agent is active.")
self._is_active = True
def clear_activity_signal(self):
"""Clear the activity signal."""
with self._lock:
self._is_active = False
def invoke(self, agent: Agent | None = None, is_joining: bool = True) -> AsyncIterable[ChatMessageContent]:
"""Invoke the agent asynchronously."""
raise NotImplementedError("Subclasses should implement this method")
async def get_messages_in_descending_order(self) -> AsyncIterable[ChatMessageContent]:
"""Get messages in descending order asynchronously."""
for index in range(len(self.history.messages) - 1, -1, -1):
yield self.history.messages[index]
await asyncio.sleep(0) # Yield control to the event loop
async def get_chat_messages(self, agent: "Agent | None" = None) -> AsyncIterable[ChatMessageContent]:
"""Get chat messages asynchronously."""
self.set_activity_or_throw()
logger.info("Getting chat messages")
messages: AsyncIterable[ChatMessageContent] | None = None
try:
if agent is None:
messages = self.get_messages_in_descending_order()
else:
channel_key = self._get_agent_hash(agent)
channel = await self._synchronize_channel(channel_key)
if channel is not None:
messages = channel.get_history()
if messages is not None:
async for message in messages:
yield message
finally:
self.clear_activity_signal()
async def _synchronize_channel(self, channel_key: str) -> AgentChannel | None:
"""Synchronize a channel."""
channel = self.agent_channels.get(channel_key, None)
if channel:
await self.broadcast_queue.ensure_synchronized(ChannelReference(channel=channel, hash=channel_key))
return channel
def _get_agent_hash(self, agent: Agent):
"""Get the hash of an agent."""
hash_value = self.channel_map.get(agent, None)
if hash_value is None:
hash_value = KeyEncoder.generate_hash(agent.get_channel_keys())
self.channel_map[agent] = hash_value
return hash_value
async def add_chat_message(self, message: str | ChatMessageContent) -> None:
"""Add a chat message."""
if isinstance(message, str):
message = ChatMessageContent(role=AuthorRole.USER, content=message)
await self.add_chat_messages([message])
async def add_chat_messages(self, messages: list[ChatMessageContent]) -> None:
"""Add chat messages."""
self.set_activity_or_throw()
for message in messages:
if message.role == AuthorRole.SYSTEM:
error_message = "System messages cannot be added to the chat history."
logger.error(error_message)
raise AgentChatException(error_message)
logger.info(f"Adding `{len(messages)}` agent chat messages")
try:
self.history.messages.extend(messages)
# Broadcast message to other channels (in parallel)
# Note: Able to queue messages without synchronizing channels.
channel_refs = [ChannelReference(channel=channel, hash=key) for key, channel in self.agent_channels.items()]
await self.broadcast_queue.enqueue(channel_refs, messages)
finally:
self.clear_activity_signal()
async def _get_or_create_channel(self, agent: Agent) -> AgentChannel:
"""Get or create a channel."""
channel_key = self._get_agent_hash(agent)
channel = await self._synchronize_channel(channel_key)
if channel is None:
channel = await agent.create_channel()
self.agent_channels[channel_key] = channel
if len(self.history.messages) > 0:
await channel.receive(self.history.messages)
return channel
async def invoke_agent(self, agent: Agent) -> AsyncIterable[ChatMessageContent]:
"""Invoke an agent asynchronously."""
self.set_activity_or_throw()
logger.info(f"Invoking agent {agent.name}")
try:
channel: AgentChannel = await self._get_or_create_channel(agent)
messages: list[ChatMessageContent] = []
async for is_visible, message in channel.invoke(agent):
messages.append(message)
self.history.messages.append(message)
if is_visible:
yield message
# Broadcast message to other channels (in parallel)
# Note: Able to queue messages without synchronizing channels.
channel_refs = [
ChannelReference(channel=ch, hash=key) for key, ch in self.agent_channels.items() if ch != channel
]
await self.broadcast_queue.enqueue(channel_refs, messages)
finally:
self.clear_activity_signal()
async def invoke_agent_stream(self, agent: Agent) -> AsyncIterable[ChatMessageContent]:
"""Invoke an agent stream asynchronously."""
self.set_activity_or_throw()
logger.info(f"Invoking agent {agent.name}")
try:
channel: AgentChannel = await self._get_or_create_channel(agent)
messages: list[ChatMessageContent] = []
async for message in channel.invoke_stream(agent, messages):
yield message
for message in messages:
self.history.messages.append(message)
# Broadcast message to other channels (in parallel)
# Note: Able to queue messages without synchronizing channels.
channel_refs = [
ChannelReference(channel=ch, hash=key) for key, ch in self.agent_channels.items() if ch != channel
]
await self.broadcast_queue.enqueue(channel_refs, messages)
finally:
self.clear_activity_signal()
async def reset(self) -> None:
"""Reset the agent chat."""
self.set_activity_or_throw()
try:
await asyncio.gather(*(channel.reset() for channel in self.agent_channels.values()))
self.agent_channels.clear()
self.channel_map.clear()
self.history.messages.clear()
finally:
self.clear_activity_signal()
@@ -0,0 +1,33 @@
# Copyright (c) Microsoft. All rights reserved.
import base64
import hashlib
from collections.abc import Iterable
from semantic_kernel.exceptions.agent_exceptions import AgentExecutionException
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class KeyEncoder:
"""A class for encoding keys."""
@staticmethod
def generate_hash(keys: Iterable[str]) -> str:
"""Generate a hash from a list of keys.
Args:
keys: A list of keys to generate the hash from.
Returns:
str: The generated hash.
Raises:
AgentExecutionException: If the keys are empty
"""
if not keys:
raise AgentExecutionException("Channel Keys must not be empty. Unable to generate channel hash.")
joined_keys = ":".join(keys)
buffer = joined_keys.encode("utf-8")
sha256_hash = hashlib.sha256(buffer).digest()
return base64.b64encode(sha256_hash).decode("utf-8")
@@ -0,0 +1,228 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from collections.abc import AsyncIterable
from copy import deepcopy
from typing import TYPE_CHECKING, Any, cast
from pydantic import Field
from semantic_kernel.agents import Agent, AgentChat
from semantic_kernel.agents.strategies import (
DefaultTerminationStrategy,
SequentialSelectionStrategy,
)
from semantic_kernel.agents.strategies.selection.selection_strategy import SelectionStrategy
from semantic_kernel.agents.strategies.termination.termination_strategy import TerminationStrategy
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.history_reducer.chat_history_reducer import ChatHistoryReducer
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.exceptions.agent_exceptions import AgentChatException
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.contents.chat_history import ChatHistory
logger: logging.Logger = logging.getLogger(__name__)
@experimental
class AgentGroupChat(AgentChat):
"""An agent chat that supports multi-turn interactions."""
agent_ids: set[str]
agents: list[Agent] = Field(default_factory=list)
is_complete: bool = False
termination_strategy: TerminationStrategy = Field(
default_factory=DefaultTerminationStrategy,
description="The termination strategy to use. The default strategy never terminates and has a max iterations of 5.", # noqa: E501
)
selection_strategy: SelectionStrategy = Field(default_factory=SequentialSelectionStrategy)
def __init__(
self,
agents: list[Agent] | None = None,
termination_strategy: TerminationStrategy | None = None,
selection_strategy: SelectionStrategy | None = None,
chat_history: "ChatHistory | None" = None,
) -> None:
"""Initialize a new instance of AgentGroupChat.
Args:
agents: The agents to add to the group chat.
termination_strategy: The termination strategy to use.
selection_strategy: The selection strategy
chat_history: The chat history.
"""
agent_ids = {agent.id for agent in agents} if agents else set()
if agents is None:
agents = []
args: dict[str, Any] = {
"agents": agents,
"agent_ids": agent_ids,
}
if termination_strategy is not None:
args["termination_strategy"] = termination_strategy
if selection_strategy is not None:
args["selection_strategy"] = selection_strategy
if chat_history is not None:
args["history"] = chat_history
super().__init__(**args)
def add_agent(self, agent: Agent) -> None:
"""Add an agent to the group chat.
Args:
agent: The agent to add.
"""
if agent.id not in self.agent_ids:
self.agent_ids.add(agent.id)
self.agents.append(agent)
async def invoke_single_turn(self, agent: Agent) -> AsyncIterable[ChatMessageContent]:
"""Invoke the agent chat for a single turn.
Args:
agent: The agent to invoke.
Yields:
The chat message.
"""
async for message in self.invoke(agent, is_joining=True):
if message.role == AuthorRole.ASSISTANT:
task = self.termination_strategy.should_terminate(agent, self.history.messages)
self.is_complete = await task
yield message
async def invoke_stream_single_turn(self, agent: Agent) -> AsyncIterable[ChatMessageContent]:
"""Invoke the agent chat for a single turn.
Args:
agent: The agent to invoke.
Yields:
The chat message.
"""
async for message in self.invoke_stream(agent, is_joining=True):
yield message
self.is_complete = await self.termination_strategy.should_terminate(agent, self.history.messages)
async def invoke(self, agent: Agent | None = None, is_joining: bool = True) -> AsyncIterable[ChatMessageContent]:
"""Invoke the agent chat asynchronously.
Handles both group interactions and single agent interactions based on the provided arguments.
Args:
agent: The agent to invoke. If not provided, the method processes all agents in the chat.
is_joining: Controls whether the agent joins the chat. Defaults to True.
Yields:
The chat message.
"""
if agent is not None:
if is_joining:
self.add_agent(agent)
async for message in super().invoke_agent(agent):
if message.role == AuthorRole.ASSISTANT:
task = self.termination_strategy.should_terminate(agent, self.history.messages)
self.is_complete = await task
yield message
return
if not self.agents:
raise AgentChatException("No agents are available")
if self.is_complete:
if not self.termination_strategy.automatic_reset:
raise AgentChatException("Chat is already complete")
self.is_complete = False
for _ in range(self.termination_strategy.maximum_iterations):
try:
selected_agent = await self.selection_strategy.next(self.agents, self.history.messages)
except Exception as ex:
logger.error(f"Failed to select agent: {ex}")
raise AgentChatException("Failed to select agent") from ex
async for message in super().invoke_agent(selected_agent):
if message.role == AuthorRole.ASSISTANT:
task = self.termination_strategy.should_terminate(selected_agent, self.history.messages)
self.is_complete = await task
yield message
if self.is_complete:
break
async def invoke_stream(
self, agent: Agent | None = None, is_joining: bool = True
) -> AsyncIterable[ChatMessageContent]:
"""Invoke the agent chat stream asynchronously.
Handles both group interactions and single agent interactions based on the provided arguments.
Args:
agent: The agent to invoke. If not provided, the method processes all agents in the chat.
is_joining: Controls whether the agent joins the chat. Defaults to True.
Yields:
The chat message.
"""
if agent is not None:
if is_joining:
self.add_agent(agent)
async for message in super().invoke_agent_stream(agent):
if message.role == AuthorRole.ASSISTANT:
task = self.termination_strategy.should_terminate(agent, self.history.messages)
self.is_complete = await task
yield message
return
if not self.agents:
raise AgentChatException("No agents are available")
if self.is_complete:
if not self.termination_strategy.automatic_reset:
raise AgentChatException("Chat is already complete")
self.is_complete = False
for _ in range(self.termination_strategy.maximum_iterations):
try:
selected_agent = await self.selection_strategy.next(self.agents, self.history.messages)
except Exception as ex:
logger.error(f"Failed to select agent: {ex}")
raise AgentChatException("Failed to select agent") from ex
async for message in super().invoke_agent_stream(selected_agent):
yield message
self.is_complete = await self.termination_strategy.should_terminate(selected_agent, self.history.messages)
if self.is_complete:
break
async def reduce_history(self) -> bool:
"""Perform the reduction on the provided history, returning True if reduction occurred."""
if not isinstance(self.history, ChatHistoryReducer):
return False
result = await self.history.reduce()
if result is None:
return False
reducer = cast(ChatHistoryReducer, result)
reduced_history = deepcopy(reducer.messages)
await self.reset()
await self.add_chat_messages(reduced_history)
return True
@@ -0,0 +1,129 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from collections import deque
from dataclasses import dataclass, field
from typing import Any
from pydantic import Field, SkipValidation, ValidationError, model_validator
from semantic_kernel.agents.channels.agent_channel import AgentChannel
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class QueueReference(KernelBaseModel):
"""Utility class to associate a queue with its specific lock."""
queue: deque = Field(default_factory=deque)
queue_lock: SkipValidation[asyncio.Lock] = Field(default_factory=asyncio.Lock, exclude=True)
receive_task: SkipValidation[asyncio.Task | None] = None
receive_failure: Exception | None = None
@property
def is_empty(self):
"""Check if the queue is empty."""
return len(self.queue) == 0
@model_validator(mode="before")
def validate_receive_task(cls, values: Any):
"""Validate the receive task."""
if isinstance(values, dict):
receive_task = values.get("receive_task")
if receive_task is not None and not isinstance(receive_task, asyncio.Task):
raise ValidationError("receive_task must be an instance of asyncio.Task or None")
return values
@experimental
@dataclass
class ChannelReference:
"""Tracks a channel along with its hashed key."""
hash: str
channel: AgentChannel = field(default_factory=AgentChannel)
@experimental
class BroadcastQueue(KernelBaseModel):
"""A queue for broadcasting messages to listeners."""
queues: dict[str, QueueReference] = Field(default_factory=dict)
block_duration: float = 0.1
async def enqueue(self, channel_refs: list[ChannelReference], messages: list[ChatMessageContent]) -> None:
"""Enqueue a set of messages for a given channel.
Args:
channel_refs: The channel references.
messages: The messages to broadcast.
"""
for channel_ref in channel_refs:
if channel_ref.hash not in self.queues:
self.queues[channel_ref.hash] = QueueReference()
queue_ref = self.queues[channel_ref.hash]
async with queue_ref.queue_lock:
queue_ref.queue.append(messages)
if not queue_ref.receive_task or queue_ref.receive_task.done():
queue_ref.receive_task = asyncio.create_task(self.receive(channel_ref, queue_ref))
async def ensure_synchronized(self, channel_ref: ChannelReference) -> None:
"""Blocks until a channel-queue is not in a receive state to ensure that channel history is complete.
Args:
channel_ref: The channel reference.
"""
if channel_ref.hash not in self.queues:
return
queue_ref = self.queues[channel_ref.hash]
while True:
async with queue_ref.queue_lock:
is_empty = queue_ref.is_empty
if queue_ref.receive_failure is not None:
failure = queue_ref.receive_failure
queue_ref.receive_failure = None
raise Exception(
f"Unexpected failure broadcasting to channel: {type(channel_ref.channel)}, failure: {failure}"
) from failure
if not is_empty and (not queue_ref.receive_task or queue_ref.receive_task.done()):
queue_ref.receive_task = asyncio.create_task(self.receive(channel_ref, queue_ref))
if is_empty:
break
await asyncio.sleep(self.block_duration)
async def receive(self, channel_ref: ChannelReference, queue_ref: QueueReference) -> None:
"""Processes the specified queue with the provided channel, until the queue is empty.
Args:
channel_ref: The channel reference.
queue_ref: The queue reference.
"""
while True:
async with queue_ref.queue_lock:
if queue_ref.is_empty:
break
messages = queue_ref.queue[0]
try:
await channel_ref.channel.receive(messages)
except Exception as e:
queue_ref.receive_failure = e
async with queue_ref.queue_lock:
if not queue_ref.is_empty:
queue_ref.queue.popleft()
if queue_ref.receive_failure is not None or queue_ref.is_empty:
break
@@ -0,0 +1,566 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any
from openai import AsyncOpenAI
from openai.types.beta.threads.file_citation_annotation import FileCitationAnnotation
from openai.types.beta.threads.file_citation_delta_annotation import FileCitationDeltaAnnotation
from openai.types.beta.threads.file_path_annotation import FilePathAnnotation
from openai.types.beta.threads.file_path_delta_annotation import FilePathDeltaAnnotation
from openai.types.beta.threads.image_file_content_block import ImageFileContentBlock
from openai.types.beta.threads.image_file_delta_block import ImageFileDeltaBlock
from openai.types.beta.threads.message_delta_event import MessageDeltaEvent
from openai.types.beta.threads.runs import CodeInterpreterLogs
from openai.types.beta.threads.runs.code_interpreter_tool_call import CodeInterpreterOutputImage
from openai.types.beta.threads.text_content_block import TextContentBlock
from openai.types.beta.threads.text_delta_block import TextDeltaBlock
from semantic_kernel.contents.annotation_content import AnnotationContent
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.file_reference_content import FileReferenceContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.function_result_content import FunctionResultContent
from semantic_kernel.contents.image_content import ImageContent
from semantic_kernel.contents.streaming_annotation_content import StreamingAnnotationContent
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
from semantic_kernel.contents.streaming_file_reference_content import StreamingFileReferenceContent
from semantic_kernel.contents.streaming_text_content import StreamingTextContent
from semantic_kernel.contents.text_content import TextContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.exceptions.agent_exceptions import AgentExecutionException
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from openai.types.beta.threads.message import Message
from openai.types.beta.threads.run import Run
from openai.types.beta.threads.runs import RunStep
from openai.types.beta.threads.runs.tool_call import ToolCall
from openai.types.beta.threads.runs.tool_calls_step_details import ToolCallsStepDetails
###################################################################
# The methods in this file are used with OpenAIAssistantAgent #
# related code. They are used to create chat messages, or #
# generate message content. #
###################################################################
@experimental
async def create_chat_message(
client: AsyncOpenAI,
thread_id: str,
message: "ChatMessageContent",
allowed_message_roles: Sequence[str] | None = None,
) -> "Message":
"""Class method to add a chat message, callable from class or instance.
Args:
client: The client to use for creating the message.
thread_id: The thread id.
message: The chat message.
allowed_message_roles: The allowed message roles.
Defaults to [AuthorRole.USER, AuthorRole.ASSISTANT] if None.
Providing an empty list will disallow all message roles.
Returns:
Message: The message.
"""
# Set the default allowed message roles if not provided
if allowed_message_roles is None:
allowed_message_roles = [AuthorRole.USER, AuthorRole.ASSISTANT]
if message.role.value not in allowed_message_roles and message.role != AuthorRole.TOOL:
raise AgentExecutionException(
f"Invalid message role `{message.role.value}`. Allowed roles are {allowed_message_roles}."
)
message_contents: list[dict[str, Any]] = get_message_contents(message=message)
return await client.beta.threads.messages.create(
thread_id=thread_id,
role="assistant" if message.role == AuthorRole.TOOL else message.role.value, # type: ignore
content=message_contents, # type: ignore
)
@experimental
def get_message_contents(message: "ChatMessageContent") -> list[dict[str, Any]]:
"""Get the message contents.
Args:
message: The message.
"""
contents: list[dict[str, Any]] = []
for content in message.items:
match content:
case TextContent():
# Make sure text is a string
final_text = content.text
if not isinstance(final_text, str):
if isinstance(final_text, (list, tuple)):
final_text = " ".join(map(str, final_text))
else:
final_text = str(final_text)
contents.append({"type": "text", "text": final_text})
case ImageContent():
if content.uri:
contents.append(content.to_dict())
case FileReferenceContent():
contents.append({
"type": "image_file",
"image_file": {"file_id": content.file_id},
})
case FunctionResultContent():
final_result = content.result
match final_result:
case str():
contents.append({"type": "text", "text": final_result})
case list() | tuple():
contents.append({"type": "text", "text": " ".join(map(str, final_result))})
case _:
contents.append({"type": "text", "text": str(final_result)})
return contents
@experimental
def generate_message_content(
assistant_name: str, message: "Message", completed_step: "RunStep | None" = None
) -> ChatMessageContent:
"""Generate message content."""
role = AuthorRole(message.role)
metadata = (
{
"created_at": completed_step.created_at,
"message_id": message.id, # message needs to be defined in context
"step_id": completed_step.id,
"run_id": completed_step.run_id,
"thread_id": completed_step.thread_id,
"assistant_id": completed_step.assistant_id,
"usage": completed_step.usage,
}
if completed_step is not None
else None
)
content: ChatMessageContent = ChatMessageContent(role=role, name=assistant_name, metadata=metadata) # type: ignore
for item_content in message.content:
if item_content.type == "text":
assert isinstance(item_content, TextContentBlock) # nosec
content.items.append(
TextContent(
text=item_content.text.value,
)
)
for annotation in item_content.text.annotations:
content.items.append(generate_annotation_content(annotation))
elif item_content.type == "image_file":
assert isinstance(item_content, ImageFileContentBlock) # nosec
content.items.append(
FileReferenceContent(
file_id=item_content.image_file.file_id,
)
)
return content
@experimental
def generate_streaming_message_content(
assistant_name: str,
message_delta_event: "MessageDeltaEvent",
completed_step: "RunStep | None" = None,
) -> StreamingChatMessageContent:
"""Generate streaming message content from a MessageDeltaEvent."""
delta = message_delta_event.delta
metadata = (
{
"created_at": completed_step.created_at,
"message_id": message_delta_event.id, # message needs to be defined in context
"step_id": completed_step.id,
"run_id": completed_step.run_id,
"thread_id": completed_step.thread_id,
"assistant_id": completed_step.assistant_id,
"usage": completed_step.usage,
}
if completed_step is not None
else None
)
# Determine the role
role = AuthorRole(delta.role) if delta.role is not None else AuthorRole("assistant")
items: list[StreamingTextContent | StreamingAnnotationContent | StreamingFileReferenceContent] = []
# Process each content block in the delta
for delta_block in delta.content or []:
if delta_block.type == "text":
assert isinstance(delta_block, TextDeltaBlock) # nosec
if delta_block.text and delta_block.text.value: # Ensure text is not None
text_value = delta_block.text.value
items.append(
StreamingTextContent(
text=text_value,
choice_index=delta_block.index,
)
)
# Process annotations if any
if delta_block.text.annotations:
for annotation in delta_block.text.annotations or []:
if isinstance(annotation, (FileCitationDeltaAnnotation, FilePathDeltaAnnotation)):
items.append(generate_streaming_annotation_content(annotation))
elif delta_block.type == "image_file":
assert isinstance(delta_block, ImageFileDeltaBlock) # nosec
if delta_block.image_file and delta_block.image_file.file_id:
file_id = delta_block.image_file.file_id
items.append(
StreamingFileReferenceContent(
file_id=file_id,
)
)
return StreamingChatMessageContent(role=role, name=assistant_name, items=items, choice_index=0, metadata=metadata) # type: ignore
@experimental
def generate_final_streaming_message_content(
assistant_name: str,
message: "Message",
completed_step: "RunStep | None" = None,
) -> StreamingChatMessageContent:
"""Generate streaming message content from a MessageDeltaEvent."""
metadata = (
{
"created_at": completed_step.created_at,
"message_id": message.id, # message needs to be defined in context
"step_id": completed_step.id,
"run_id": completed_step.run_id,
"thread_id": completed_step.thread_id,
"assistant_id": completed_step.assistant_id,
"usage": completed_step.usage,
}
if completed_step is not None
else None
)
# Determine the role
role = AuthorRole(message.role) if message.role is not None else AuthorRole("assistant")
items: list[StreamingTextContent | StreamingAnnotationContent | StreamingFileReferenceContent] = []
# Process each content block in the delta
for item_content in message.content:
if item_content.type == "text":
assert isinstance(item_content, TextContentBlock) # nosec
items.append(StreamingTextContent(text=item_content.text.value, choice_index=0))
for annotation in item_content.text.annotations:
items.append(generate_streaming_annotation_content(annotation))
elif item_content.type == "image_file":
assert isinstance(item_content, ImageFileContentBlock) # nosec
items.append(
StreamingFileReferenceContent(
file_id=item_content.image_file.file_id,
)
)
return StreamingChatMessageContent(role=role, name=assistant_name, items=items, choice_index=0, metadata=metadata) # type: ignore
@experimental
def merge_function_results(messages: list["ChatMessageContent"], name: str) -> "ChatMessageContent":
"""Combine multiple function result content types to one chat message content type.
This method combines the FunctionResultContent items from separate ChatMessageContent messages,
and is used in the event that the `context.terminate = True` condition is met.
Args:
messages: The list of chat messages.
name: The name of the agent.
Returns:
list[ChatMessageContent]: The combined chat message content.
"""
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.function_result_content import FunctionResultContent
items: list[Any] = []
for message in messages:
items.extend([item for item in message.items if isinstance(item, FunctionResultContent)])
return ChatMessageContent(
role=AuthorRole.TOOL,
items=items,
name=name,
)
@experimental
def merge_streaming_function_results(
messages: list["ChatMessageContent | StreamingChatMessageContent"],
name: str,
ai_model_id: str | None = None,
function_invoke_attempt: int | None = None,
) -> "StreamingChatMessageContent":
"""Combine multiple streaming function result content types to one streaming chat message content type.
This method combines the FunctionResultContent items from separate StreamingChatMessageContent messages,
and is used in the event that the `context.terminate = True` condition is met.
Args:
messages: The list of streaming chat message content types.
name: The name of the agent.
ai_model_id: The AI model ID.
function_invoke_attempt: The function invoke attempt.
Returns:
The combined streaming chat message content type.
"""
from semantic_kernel.contents.function_result_content import FunctionResultContent
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
items: list[Any] = []
for message in messages:
items.extend([item for item in message.items if isinstance(item, FunctionResultContent)])
return StreamingChatMessageContent(
name=name,
role=AuthorRole.TOOL,
items=items,
choice_index=0,
ai_model_id=ai_model_id,
function_invoke_attempt=function_invoke_attempt,
)
@experimental
def generate_function_call_content(agent_name: str, fccs: list[FunctionCallContent]) -> ChatMessageContent:
"""Generate function call content.
Args:
agent_name: The agent name.
fccs: The function call contents.
Returns:
ChatMessageContent: The chat message content containing the function call content as the items.
"""
return ChatMessageContent(role=AuthorRole.ASSISTANT, name=agent_name, items=fccs) # type: ignore
@experimental
def generate_function_result_content(
agent_name: str, function_step: FunctionCallContent, tool_call: "ToolCall"
) -> ChatMessageContent:
"""Generate function result content."""
function_call_content: ChatMessageContent = ChatMessageContent(role=AuthorRole.TOOL, name=agent_name) # type: ignore
function_call_content.items.append(
FunctionResultContent(
function_name=function_step.function_name,
plugin_name=function_step.plugin_name,
id=function_step.id,
result=tool_call.function.output, # type: ignore
)
)
return function_call_content
@experimental
def get_function_call_contents(run: "Run", function_steps: dict[str, FunctionCallContent]) -> list[FunctionCallContent]:
"""Extract function call contents from the run.
Args:
run: The run.
function_steps: The function steps
Returns:
The list of function call contents.
"""
function_call_contents: list[FunctionCallContent] = []
required_action = getattr(run, "required_action", None)
if not required_action or not getattr(required_action, "submit_tool_outputs", False):
return function_call_contents
for tool in required_action.submit_tool_outputs.tool_calls:
fcc = FunctionCallContent(
id=tool.id,
index=getattr(tool, "index", None),
name=tool.function.name,
arguments=tool.function.arguments,
)
function_call_contents.append(fcc)
function_steps[tool.id] = fcc
return function_call_contents
@experimental
def generate_code_interpreter_content(agent_name: str, code: str) -> "ChatMessageContent":
"""Generate code interpreter content.
Args:
agent_name: The agent name.
code: The code.
Returns:
ChatMessageContent: The chat message content.
"""
return ChatMessageContent(
role=AuthorRole.ASSISTANT,
content=code,
name=agent_name,
metadata={"code": True},
)
@experimental
def generate_streaming_function_content(
agent_name: str, step_details: "ToolCallsStepDetails"
) -> "StreamingChatMessageContent":
"""Generate streaming function content.
Args:
agent_name: The agent name.
step_details: The function step.
Returns:
StreamingChatMessageContent: The chat message content.
"""
items: list[FunctionCallContent] = []
for tool in step_details.tool_calls:
if tool.type == "function":
items.append(
FunctionCallContent(
id=tool.id,
index=getattr(tool, "index", None),
name=tool.function.name,
arguments=tool.function.arguments,
)
)
return (
StreamingChatMessageContent(
role=AuthorRole.ASSISTANT,
name=agent_name,
items=items, # type: ignore
choice_index=0,
)
if len(items) > 0
else None
)
@experimental
def generate_streaming_code_interpreter_content(
agent_name: str, step_details: "ToolCallsStepDetails"
) -> "StreamingChatMessageContent | None":
"""Generate code interpreter content.
Args:
agent_name: The agent name.
step_details: The current step details.
Returns:
StreamingChatMessageContent: The chat message content.
"""
items: list[StreamingTextContent | StreamingFileReferenceContent] = []
metadata: dict[str, bool] = {}
for index, tool in enumerate(step_details.tool_calls):
if tool.type == "code_interpreter":
if tool.code_interpreter.input:
items.append(
StreamingTextContent(
choice_index=index,
text=tool.code_interpreter.input,
)
)
metadata["code"] = True
if tool.code_interpreter.outputs:
for output in tool.code_interpreter.outputs:
if isinstance(output, CodeInterpreterOutputImage) and output.image.file_id:
items.append(
StreamingFileReferenceContent(
file_id=output.image.file_id,
)
)
if isinstance(output, CodeInterpreterLogs) and output.logs:
items.append(
StreamingTextContent(
choice_index=index,
text=output.logs,
)
)
return (
StreamingChatMessageContent(
role=AuthorRole.ASSISTANT,
name=agent_name,
items=items, # type: ignore
choice_index=0,
metadata=metadata if metadata else None,
)
if len(items) > 0
else None
)
@experimental
def generate_annotation_content(annotation: FileCitationAnnotation | FilePathAnnotation) -> AnnotationContent:
"""Generate annotation content."""
file_id = None
match annotation:
case FilePathAnnotation():
file_id = annotation.file_path.file_id
case FileCitationAnnotation():
file_id = annotation.file_citation.file_id
return AnnotationContent(
file_id=file_id,
quote=annotation.text,
start_index=annotation.start_index,
end_index=annotation.end_index,
)
@experimental
def generate_streaming_annotation_content(
annotation: FileCitationAnnotation | FilePathAnnotation | FilePathDeltaAnnotation | FileCitationDeltaAnnotation,
) -> StreamingAnnotationContent:
"""Generate streaming annotation content."""
file_id = None
match annotation:
case FilePathAnnotation():
file_id = annotation.file_path.file_id
case FileCitationAnnotation():
file_id = annotation.file_citation.file_id
case FilePathDeltaAnnotation():
file_id = annotation.file_path.file_id if annotation.file_path is not None else None
case FileCitationDeltaAnnotation():
file_id = annotation.file_citation.file_id if annotation.file_citation is not None else None
return StreamingAnnotationContent(
file_id=file_id,
quote=annotation.text,
start_index=annotation.start_index,
end_index=annotation.end_index,
)
@experimental
def generate_function_call_streaming_content(
agent_name: str,
fccs: list[FunctionCallContent],
) -> StreamingChatMessageContent:
"""Generate function call content.
Args:
agent_name: The agent name.
fccs: The function call contents.
Returns:
StreamingChatMessageContent: The chat message content containing the function call content as the items.
"""
return StreamingChatMessageContent(role=AuthorRole.ASSISTANT, choice_index=0, name=agent_name, items=fccs) # type: ignore
@@ -0,0 +1,950 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
from collections.abc import AsyncIterable, Iterable, Sequence
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar, cast
from openai import AsyncOpenAI
from openai._types import Omit, omit
from openai.types.beta.code_interpreter_tool import CodeInterpreterTool
from openai.types.beta.file_search_tool import FileSearchTool
from openai.types.beta.threads.run_create_params import AdditionalMessage, AdditionalMessageAttachment
from openai.types.beta.threads.runs import (
MessageCreationStepDetails,
RunStep,
RunStepDeltaEvent,
ToolCallDeltaObject,
ToolCallsStepDetails,
)
from semantic_kernel.agents.open_ai.assistant_content_generation import (
generate_code_interpreter_content,
generate_final_streaming_message_content,
generate_function_call_content,
generate_function_call_streaming_content,
generate_function_result_content,
generate_message_content,
generate_streaming_code_interpreter_content,
generate_streaming_message_content,
get_function_call_contents,
get_message_contents,
merge_streaming_function_results,
)
from semantic_kernel.agents.open_ai.function_action_result import FunctionActionResult
from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions
from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.connectors.ai.function_choice_type import FunctionChoiceType
from semantic_kernel.contents.file_reference_content import FileReferenceContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.streaming_file_reference_content import StreamingFileReferenceContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.exceptions.agent_exceptions import AgentExecutionException, AgentInvokeException
from semantic_kernel.functions.kernel_arguments import KernelArguments
from semantic_kernel.utils.feature_stage_decorator import release_candidate
if TYPE_CHECKING:
from openai import AsyncOpenAI
from openai.types.beta.assistant_response_format_option_param import AssistantResponseFormatOptionParam
from openai.types.beta.assistant_tool_param import AssistantToolParam
from openai.types.beta.threads.message import Message
from openai.types.beta.threads.run import Run
from openai.types.beta.threads.run_create_params import AdditionalMessageAttachmentTool, TruncationStrategy
from semantic_kernel.agents.open_ai.openai_assistant_agent import OpenAIAssistantAgent
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
from semantic_kernel.filters.auto_function_invocation.auto_function_invocation_context import (
AutoFunctionInvocationContext,
)
from semantic_kernel.kernel import Kernel
_T = TypeVar("_T", bound="AssistantThreadActions")
logger: logging.Logger = logging.getLogger(__name__)
@release_candidate
class AssistantThreadActions:
"""Assistant Thread Actions class."""
polling_status: ClassVar[list[str]] = ["queued", "in_progress", "cancelling"]
error_message_states: ClassVar[list[str]] = ["failed", "cancelled", "expired", "incomplete"]
tool_metadata: ClassVar[dict[str, Sequence[Any]]] = {
"file_search": [{"type": "file_search"}],
"code_interpreter": [{"type": "code_interpreter"}],
}
# region Messaging Handling Methods
@classmethod
async def create_message(
cls: type[_T],
client: "AsyncOpenAI",
thread_id: str,
message: "str | ChatMessageContent",
allowed_message_roles: Sequence[str] | None = None,
**kwargs: Any,
) -> "Message | None":
"""Create a message in the thread.
Args:
client: The client to use to create the message.
thread_id: The ID of the thread to create the message in.
message: The message to create.
allowed_message_roles: The allowed message roles.
Defaults to [AuthorRole.USER, AuthorRole.ASSISTANT] if None.
Providing an empty list will disallow all message roles.
kwargs: Additional keyword arguments.
Returns:
The created message.
"""
from semantic_kernel.contents.chat_message_content import ChatMessageContent
if isinstance(message, str):
message = ChatMessageContent(role=AuthorRole.USER, content=message)
if any(isinstance(item, FunctionCallContent) for item in message.items):
return None
# Set the default allowed message roles if not provided
if allowed_message_roles is None:
allowed_message_roles = [AuthorRole.USER, AuthorRole.ASSISTANT]
if message.role.value not in allowed_message_roles and message.role != AuthorRole.TOOL:
raise AgentExecutionException(
f"Invalid message role `{message.role.value}`. Allowed roles are {allowed_message_roles}."
)
message_contents: list[dict[str, Any]] = get_message_contents(message=message)
return await client.beta.threads.messages.create(
thread_id=thread_id,
role="assistant" if message.role == AuthorRole.TOOL else message.role.value, # type: ignore
content=message_contents, # type: ignore
**kwargs,
)
# endregion
# region Invocation Methods
@classmethod
async def invoke(
cls: type[_T],
*,
agent: "OpenAIAssistantAgent",
thread_id: str,
additional_instructions: str | None = None,
additional_messages: "list[ChatMessageContent] | None" = None,
arguments: KernelArguments | None = None,
instructions_override: str | None = None,
kernel: "Kernel | None" = None,
max_completion_tokens: int | None = None,
max_prompt_tokens: int | None = None,
metadata: dict[str, str] | None = None,
model: str | None = None,
parallel_tool_calls: bool | None = None,
reasoning_effort: Literal["low", "medium", "high"] | None = None,
response_format: "AssistantResponseFormatOptionParam | None" = None,
tools: "list[AssistantToolParam] | None" = None,
temperature: float | None = None,
top_p: float | None = None,
truncation_strategy: "TruncationStrategy | None" = None,
polling_options: RunPollingOptions | None = None,
function_choice_behavior: FunctionChoiceBehavior | None = None,
**kwargs: Any,
) -> AsyncIterable[tuple[bool, "ChatMessageContent"]]:
"""Invoke the assistant.
Args:
agent: The assistant agent.
thread_id: The thread ID.
arguments: The kernel arguments.
kernel: The kernel.
instructions_override: The instructions override.
additional_instructions: The additional instructions.
additional_messages: The additional messages.
max_completion_tokens: The maximum completion tokens.
max_prompt_tokens: The maximum prompt tokens.
metadata: The metadata.
model: The model.
parallel_tool_calls: The parallel tool calls.
reasoning_effort: The reasoning effort.
response_format: The response format.
tools: The SDK-level tools (e.g. CodeInterpreter, FileSearch). When provided,
overrides the tools from the agent definition. Does not affect kernel function availability;
use function_choice_behavior for that.
temperature: The temperature.
top_p: The top p.
truncation_strategy: The truncation strategy.
polling_options: The polling options defined at the run-level. These will override the agent-level
polling options.
function_choice_behavior: Controls which kernel functions are allowed to execute during this run.
Use FunctionChoiceBehavior.Auto(filters={"included_functions": [...]}) to restrict to specific
functions. Only Auto is supported; other types will raise an error.
kwargs: Additional keyword arguments.
Returns:
An async iterable of tuple of the visibility of the message and the chat message content.
"""
arguments = KernelArguments() if arguments is None else KernelArguments(**arguments, **kwargs)
kernel = kernel or agent.kernel
cls._validate_function_choice_behavior(function_choice_behavior)
tools = cls._get_tools(
agent=agent, kernel=kernel, tools_override=tools, function_choice_behavior=function_choice_behavior
) # type: ignore
base_instructions = await agent.format_instructions(kernel=kernel, arguments=arguments)
merged_instructions: str = ""
if instructions_override is not None:
merged_instructions = instructions_override
elif base_instructions and additional_instructions:
merged_instructions = f"{base_instructions}\n\n{additional_instructions}"
else:
merged_instructions = base_instructions or additional_instructions or ""
# form run options
run_options = cls._generate_options(
agent=agent,
model=model,
response_format=response_format,
temperature=temperature,
top_p=top_p,
metadata=metadata,
parallel_tool_calls_enabled=parallel_tool_calls,
truncation_message_count=truncation_strategy,
max_completion_tokens=max_completion_tokens,
max_prompt_tokens=max_prompt_tokens,
additional_messages=additional_messages,
reasoning_effort=reasoning_effort,
)
run_options = {k: v for k, v in run_options.items() if v is not None}
run = await agent.client.beta.threads.runs.create(
assistant_id=agent.id,
thread_id=thread_id,
instructions=merged_instructions or agent.instructions,
tools=tools, # type: ignore
**run_options,
)
processed_step_ids = set()
function_steps: dict[str, "FunctionCallContent"] = {}
while run.status != "completed":
run = await cls._poll_run_status(
agent=agent, run=run, thread_id=thread_id, polling_options=polling_options or agent.polling_options
)
if run.status in cls.error_message_states:
error_message = ""
if run.last_error and run.last_error.message:
error_message = run.last_error.message
incomplete_details = ""
if run.incomplete_details:
incomplete_details = str(run.incomplete_details.reason)
raise AgentInvokeException(
f"Run failed with status: `{run.status}` for agent `{agent.name}` and thread `{thread_id}` "
f"with error: {error_message} or incomplete details: {incomplete_details}"
)
# Check if function calling required
if run.status == "requires_action":
logger.debug(f"Run [{run.id}] requires action for agent `{agent.name}` and thread `{thread_id}`")
fccs = get_function_call_contents(run, function_steps)
if fccs:
logger.debug(
f"Yielding `generate_function_call_content` for agent `{agent.name}` and "
f"thread `{thread_id}`, visibility False"
)
yield False, generate_function_call_content(agent_name=agent.name, fccs=fccs)
from semantic_kernel.contents.chat_history import ChatHistory
chat_history = ChatHistory()
_ = await cls._invoke_function_calls(
kernel=kernel,
fccs=fccs,
chat_history=chat_history,
arguments=arguments,
function_choice_behavior=function_choice_behavior,
)
tool_outputs = cls._format_tool_outputs(fccs, chat_history)
await agent.client.beta.threads.runs.submit_tool_outputs(
run_id=run.id,
thread_id=thread_id,
tool_outputs=tool_outputs, # type: ignore
)
logger.debug(f"Submitted tool outputs for agent `{agent.name}` and thread `{thread_id}`")
continue
steps_response = await agent.client.beta.threads.runs.steps.list(run_id=run.id, thread_id=thread_id)
logger.debug(f"Called for steps_response for run [{run.id}] agent `{agent.name}` and thread `{thread_id}`")
steps: list[RunStep] = steps_response.data
def sort_key(step: RunStep):
# Put tool_calls first, then message_creation
# If multiple steps share a type, break ties by completed_at
return (0 if step.type == "tool_calls" else 1, step.completed_at)
completed_steps_to_process = sorted(
[s for s in steps if s.completed_at is not None and s.id not in processed_step_ids], key=sort_key
)
logger.debug(
f"Completed steps to process for run [{run.id}] agent `{agent.name}` and thread `{thread_id}` "
f"with length `{len(completed_steps_to_process)}`"
)
message_count = 0
for completed_step in completed_steps_to_process:
if completed_step.type == "tool_calls":
logger.debug(
f"Entering step type tool_calls for run [{run.id}], agent `{agent.name}` and "
f"thread `{thread_id}`"
)
assert hasattr(completed_step.step_details, "tool_calls") # nosec
tool_call_details = cast(ToolCallsStepDetails, completed_step.step_details)
for tool_call in tool_call_details.tool_calls:
is_visible = False
content: "ChatMessageContent | None" = None
if tool_call.type == "code_interpreter":
logger.debug(
f"Entering step type tool_calls for run [{run.id}], [code_interpreter] for "
f"agent `{agent.name}` and thread `{thread_id}`"
)
content = generate_code_interpreter_content(
agent.name,
tool_call.code_interpreter.input, # type: ignore
)
is_visible = True
elif tool_call.type == "function":
logger.debug(
f"Entering step type tool_calls for run [{run.id}], [function] for agent "
f"`{agent.name}` and thread `{thread_id}`"
)
function_step = function_steps.get(tool_call.id)
assert function_step is not None # nosec
content = generate_function_result_content(
agent_name=agent.name, function_step=function_step, tool_call=tool_call
)
if content:
message_count += 1
logger.debug(
f"Yielding tool_message for run [{run.id}], agent `{agent.name}` and thread "
f"`{thread_id}` and message count `{message_count}`, is_visible `{is_visible}`"
)
yield is_visible, content
elif completed_step.type == "message_creation":
logger.debug(
f"Entering step type message_creation for run [{run.id}], agent `{agent.name}` and "
f"thread `{thread_id}`"
)
message = await cls._retrieve_message(
agent=agent,
thread_id=thread_id,
message_id=completed_step.step_details.message_creation.message_id, # type: ignore
)
if message:
content = generate_message_content(agent.name, message, completed_step)
if content and len(content.items) > 0:
message_count += 1
logger.debug(
f"Yielding message_creation for run [{run.id}], agent `{agent.name}` and "
f"thread `{thread_id}` and message count `{message_count}`, is_visible `{True}`"
)
yield True, content
processed_step_ids.add(completed_step.id)
@classmethod
async def invoke_stream(
cls: type[_T],
*,
agent: "OpenAIAssistantAgent",
thread_id: str,
additional_instructions: str | None = None,
additional_messages: "list[ChatMessageContent] | None" = None,
arguments: KernelArguments | None = None,
instructions_override: str | None = None,
kernel: "Kernel | None" = None,
max_completion_tokens: int | None = None,
max_prompt_tokens: int | None = None,
metadata: dict[str, str] | None = None,
model: str | None = None,
output_messages: list["ChatMessageContent"] | None = None,
parallel_tool_calls: bool | None = None,
reasoning_effort: Literal["low", "medium", "high"] | None = None,
response_format: "AssistantResponseFormatOptionParam | None" = None,
tools: "list[AssistantToolParam] | None" = None,
temperature: float | None = None,
top_p: float | None = None,
truncation_strategy: "TruncationStrategy | None" = None,
function_choice_behavior: FunctionChoiceBehavior | None = None,
**kwargs: Any,
) -> AsyncIterable["StreamingChatMessageContent"]:
"""Invoke the assistant.
Args:
agent: The assistant agent.
thread_id: The thread ID.
arguments: The kernel arguments.
kernel: The kernel.
instructions_override: The instructions override.
additional_instructions: The additional instructions.
additional_messages: The additional messages.
max_completion_tokens: The maximum completion tokens.
max_prompt_tokens: The maximum prompt tokens.
messages: The messages that act as a receiver for completed messages.
metadata: The metadata.
model: The model.
output_messages: The output messages received from the agent. These are full content messages
formed from the streamed chunks.
parallel_tool_calls: The parallel tool calls.
reasoning_effort: The reasoning effort.
response_format: The response format.
tools: The SDK-level tools (e.g. CodeInterpreter, FileSearch). When provided,
overrides the tools from the agent definition. Does not affect kernel function availability;
use function_choice_behavior for that.
temperature: The temperature.
top_p: The top p.
truncation_strategy: The truncation strategy.
function_choice_behavior: Controls which kernel functions are allowed to execute during this run.
Use FunctionChoiceBehavior.Auto(filters={"included_functions": [...]}) to restrict to specific
functions. Only Auto is supported; other types will raise an error.
kwargs: Additional keyword arguments.
Returns:
An async iterable of StreamingChatMessageContent.
"""
arguments = KernelArguments() if arguments is None else KernelArguments(**arguments, **kwargs)
kernel = kernel or agent.kernel
cls._validate_function_choice_behavior(function_choice_behavior)
tools = cls._get_tools(
agent=agent, kernel=kernel, tools_override=tools, function_choice_behavior=function_choice_behavior
) # type: ignore
base_instructions = await agent.format_instructions(kernel=kernel, arguments=arguments)
merged_instructions: str = ""
if instructions_override is not None:
merged_instructions = instructions_override
elif base_instructions and additional_instructions:
merged_instructions = f"{base_instructions}\n\n{additional_instructions}"
else:
merged_instructions = base_instructions or additional_instructions or ""
# form run options
run_options = cls._generate_options(
agent=agent,
model=model,
response_format=response_format,
temperature=temperature,
top_p=top_p,
metadata=metadata,
parallel_tool_calls_enabled=parallel_tool_calls,
truncation_message_count=truncation_strategy,
max_completion_tokens=max_completion_tokens,
max_prompt_tokens=max_prompt_tokens,
additional_messages=additional_messages,
reasoning_effort=reasoning_effort,
)
run_options = {k: v for k, v in run_options.items() if v is not None}
stream = agent.client.beta.threads.runs.stream(
assistant_id=agent.id,
thread_id=thread_id,
instructions=merged_instructions or agent.instructions,
tools=tools, # type: ignore
**run_options,
)
function_steps: dict[str, "FunctionCallContent"] = {}
active_messages: dict[str, RunStep] = {}
while True:
async with stream as response_stream:
async for event in response_stream:
if event.event == "thread.run.created":
run = event.data
logger.info(f"Assistant run created with ID: {run.id}")
elif event.event == "thread.run.in_progress":
run = event.data
logger.info(f"Assistant run in progress with ID: {run.id}")
elif event.event == "thread.message.delta":
content = generate_streaming_message_content(agent.name, event.data)
yield content
elif event.event == "thread.run.step.completed":
step_completed = cast(RunStep, event.data)
logger.info(f"Run step completed with ID: {event.data.id}")
if isinstance(step_completed.step_details, MessageCreationStepDetails):
message_id = step_completed.step_details.message_creation.message_id
if message_id not in active_messages:
active_messages[message_id] = event.data
elif event.event == "thread.run.step.delta":
run_step_event: RunStepDeltaEvent = event.data
details = run_step_event.delta.step_details
if not details:
continue
step_details = event.data.delta.step_details
if isinstance(details, ToolCallDeltaObject) and details.tool_calls:
for tool_call in details.tool_calls:
tool_content = None
content_is_visible = False
# Function Calling-related content is emitted as a single message
# via the `on_intermediate_message` callback.
if tool_call.type == "code_interpreter":
tool_content = generate_streaming_code_interpreter_content(agent.name, step_details)
content_is_visible = True
if tool_content:
if output_messages is not None and not content_is_visible:
output_messages.append(tool_content)
if content_is_visible:
yield tool_content
elif event.event == "thread.run.requires_action":
run = event.data
action_result = await cls._handle_streaming_requires_action(
agent.name,
kernel,
run,
function_steps,
arguments,
function_choice_behavior=function_choice_behavior,
)
if action_result is None:
raise AgentInvokeException(
f"Function call required but no function steps found for agent `{agent.name}` "
f"thread: {thread_id}."
)
for content in (
action_result.function_call_streaming_content,
action_result.function_result_streaming_content,
):
if content and output_messages is not None:
output_messages.append(content)
stream = agent.client.beta.threads.runs.submit_tool_outputs_stream(
run_id=run.id,
thread_id=thread_id,
tool_outputs=action_result.tool_outputs, # type: ignore
)
break
elif event.event == "thread.run.completed":
run = event.data
logger.info(f"Run completed with ID: {run.id}")
if len(active_messages) > 0:
for id in active_messages:
step: RunStep = active_messages[id]
message = await cls._retrieve_message(
agent=agent,
thread_id=thread_id,
message_id=id, # type: ignore
)
if message and message.content:
content = generate_final_streaming_message_content(agent.name, message, step)
if output_messages is not None:
output_messages.append(content)
return
elif event.event == "thread.run.failed":
run = event.data # type: ignore
error_message = ""
if run.last_error and run.last_error.message:
error_message = run.last_error.message
raise AgentInvokeException(
f"Run failed with status: `{run.status}` for agent `{agent.name}` and thread `{thread_id}` "
f"with error: {error_message}"
)
else:
# If the inner loop completes without encountering a 'break', exit the outer loop
break
@classmethod
async def _handle_streaming_requires_action(
cls: type[_T],
agent_name: str,
kernel: "Kernel",
run: "Run",
function_steps: dict[str, "FunctionCallContent"],
arguments: KernelArguments,
function_choice_behavior: FunctionChoiceBehavior | None = None,
**kwargs: Any,
) -> FunctionActionResult | None:
"""Handle the requires action event for a streaming run."""
fccs = get_function_call_contents(run, function_steps)
if fccs:
function_call_streaming_content = generate_function_call_streaming_content(agent_name=agent_name, fccs=fccs)
from semantic_kernel.contents.chat_history import ChatHistory
chat_history = ChatHistory() if kwargs.get("chat_history") is None else kwargs["chat_history"]
results = await cls._invoke_function_calls(
kernel=kernel,
fccs=fccs,
chat_history=chat_history,
arguments=arguments,
function_choice_behavior=function_choice_behavior,
)
function_result_streaming_content = merge_streaming_function_results(
messages=chat_history.messages[-len(results) :],
name=agent_name,
)
tool_outputs = cls._format_tool_outputs(fccs, chat_history)
return FunctionActionResult(
function_call_streaming_content,
function_result_streaming_content,
tool_outputs,
)
return None
# endregion
@classmethod
async def get_messages(
cls: type[_T],
client: AsyncOpenAI,
thread_id: str,
sort_order: Literal["asc", "desc"] | None = None,
) -> AsyncIterable["ChatMessageContent"]:
"""Get messages from the thread.
Args:
client: The client to use to get the messages.
thread_id: The ID of the thread to get the messages from.
sort_order: The sort order of the messages.
Returns:
An async iterable of ChatMessageContent.
"""
agent_names: dict[str, Any] = {}
last_id: str | Omit = omit
while True:
messages = await client.beta.threads.messages.list(
thread_id=thread_id,
order=sort_order, # type: ignore
after=last_id,
)
if not messages:
break
for message in messages.data:
last_id = message.id
if message.assistant_id and message.assistant_id.strip() not in agent_names:
agent = await client.beta.assistants.retrieve(message.assistant_id)
if agent.name and agent.name.strip():
agent_names[agent.id] = agent.name
assistant_name = agent_names.get(message.assistant_id or "", None) or message.assistant_id or message.id
content = generate_message_content(str(assistant_name), message)
if len(content.items) > 0:
yield content
if not messages.has_more:
break
@classmethod
async def _retrieve_message(
cls: type[_T], agent: "OpenAIAssistantAgent", thread_id: str, message_id: str
) -> "Message | None":
"""Retrieve a message from a thread."""
message: "Message | None" = None
count = 0
max_retries = 3
while count < max_retries:
try:
message = await agent.client.beta.threads.messages.retrieve(thread_id=thread_id, message_id=message_id)
break
except Exception as ex:
logger.error(f"Failed to retrieve message {message_id} from thread {thread_id}: {ex}")
count += 1
if count >= max_retries:
logger.error(
f"Max retries reached. Unable to retrieve message {message_id} from thread {thread_id}."
)
break
backoff_time: float = agent.polling_options.message_synchronization_delay.total_seconds() * (2**count)
await asyncio.sleep(backoff_time)
return message
@classmethod
async def _invoke_function_calls(
cls: type[_T],
kernel: "Kernel",
fccs: list["FunctionCallContent"],
chat_history: "ChatHistory",
arguments: KernelArguments,
function_choice_behavior: FunctionChoiceBehavior | None = None,
) -> list["AutoFunctionInvocationContext | None"]:
"""Invoke the function calls."""
return await asyncio.gather(
*[
kernel.invoke_function_call(
function_call=function_call,
chat_history=chat_history,
arguments=arguments,
function_behavior=function_choice_behavior,
)
for function_call in fccs
],
)
@classmethod
def _format_tool_outputs(
cls: type[_T], fccs: list["FunctionCallContent"], chat_history: "ChatHistory"
) -> list[dict[str, str]]:
"""Format the tool outputs for submission."""
from semantic_kernel.contents.function_result_content import FunctionResultContent
tool_call_lookup = {
tool_call.id: tool_call
for message in chat_history.messages
for tool_call in message.items
if isinstance(tool_call, FunctionResultContent) and tool_call.id is not None
}
return [
{"tool_call_id": fcc.id, "output": str(tool_call_lookup[fcc.id].result)}
for fcc in fccs
if fcc.id in tool_call_lookup
]
@classmethod
async def _poll_run_status(
cls: type[_T], agent: "OpenAIAssistantAgent", run: "Run", thread_id: str, polling_options: RunPollingOptions
) -> "Run":
"""Poll the run status."""
logger.info(f"Polling run status: {run.id}, threadId: {thread_id}")
try:
run = await asyncio.wait_for(
cls._poll_loop(agent, run, thread_id, polling_options),
timeout=polling_options.run_polling_timeout.total_seconds(),
)
except asyncio.TimeoutError:
timeout_duration = polling_options.run_polling_timeout
error_message = f"Polling timed out for run id: `{run.id}` and thread id: `{thread_id}` after waiting {timeout_duration}." # noqa: E501
logger.error(error_message)
raise AgentInvokeException(error_message)
logger.info(f"Polled run status: {run.status}, {run.id}, threadId: {thread_id}")
return run
@classmethod
async def _poll_loop(
cls: type[_T], agent: "OpenAIAssistantAgent", run: "Run", thread_id: str, polling_options: RunPollingOptions
) -> "Run":
"""Internal polling loop."""
count = 0
while True:
await asyncio.sleep(polling_options.get_polling_interval(count).total_seconds())
count += 1
try:
run = await agent.client.beta.threads.runs.retrieve(run.id, thread_id=thread_id)
except Exception as e:
logging.warning(f"Failed to retrieve run for run id: `{run.id}` and thread id: `{thread_id}`: {e}")
# Retry anyway
if run.status not in cls.polling_status:
break
return run
@classmethod
def _merge_options(
cls: type[_T],
*,
agent: "OpenAIAssistantAgent",
model: str | None = None,
response_format: "AssistantResponseFormatOptionParam | None" = None,
temperature: float | None = None,
top_p: float | None = None,
metadata: dict[str, str] | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""Merge run-time options with the agent-level options.
Run-level parameters take precedence.
"""
return {
"model": model if model is not None else agent.definition.model,
"response_format": response_format if response_format is not None else None,
"temperature": temperature if temperature is not None else agent.definition.temperature,
"top_p": top_p if top_p is not None else agent.definition.top_p,
"metadata": metadata if metadata is not None else agent.definition.metadata,
**kwargs,
}
@classmethod
def _generate_options(cls: type[_T], **kwargs: Any) -> dict[str, Any]:
"""Generate a dictionary of options that can be passed directly to create_run."""
merged = cls._merge_options(**kwargs)
agent = kwargs.get("agent")
trunc_count = merged.get("truncation_message_count", None)
max_completion_tokens = merged.get("max_completion_tokens", None)
max_prompt_tokens = merged.get("max_prompt_tokens", None)
parallel_tool_calls = merged.get("parallel_tool_calls_enabled", None)
additional_messages = cls._translate_additional_messages(agent, merged.get("additional_messages", None))
return {
"model": merged.get("model"),
"top_p": merged.get("top_p"),
"response_format": merged.get("response_format"),
"temperature": merged.get("temperature"),
"truncation_strategy": trunc_count,
"metadata": merged.get("metadata"),
"max_completion_tokens": max_completion_tokens,
"max_prompt_tokens": max_prompt_tokens,
"parallel_tool_calls": parallel_tool_calls,
"additional_messages": additional_messages,
}
@classmethod
def _translate_additional_messages(
cls: type[_T], agent, messages: "list[ChatMessageContent] | None"
) -> list[AdditionalMessage] | None:
"""Translate additional messages to the required format."""
if not messages:
return None
return cls._form_additional_messages(messages)
@classmethod
def _form_additional_messages(
cls: type[_T], messages: list["ChatMessageContent"]
) -> list[AdditionalMessage] | None:
"""Form the additional messages for the specified thread."""
if not messages:
return None
additional_messages = []
for message in messages:
if not message.content:
continue
message_with_all: AdditionalMessage = {
"content": message.content,
"role": "assistant" if message.role == AuthorRole.ASSISTANT else "user",
"attachments": cls._get_attachments(message) if message.items else None,
"metadata": cls._get_metadata(message) if message.metadata else None,
}
additional_messages.append(message_with_all)
return additional_messages
@classmethod
def _get_attachments(cls: type[_T], message: "ChatMessageContent") -> list[AdditionalMessageAttachment]:
return [
AdditionalMessageAttachment(
file_id=file_content.file_id,
tools=list(cls._get_tool_definition(file_content.tools)), # type: ignore
data_source=file_content.data_source if file_content.data_source else None,
)
for file_content in message.items
if isinstance(file_content, (FileReferenceContent, StreamingFileReferenceContent))
and file_content.file_id is not None
]
@classmethod
def _get_metadata(cls: type[_T], message: "ChatMessageContent") -> dict[str, str]:
"""Get the metadata for an agent message."""
return {k: str(v) if v is not None else "" for k, v in (message.metadata or {}).items()}
@classmethod
def _get_tool_definition(cls: type[_T], tools: list[Any]) -> Iterable["AdditionalMessageAttachmentTool"]:
if not tools:
return
for tool in tools:
if tool_definition := cls.tool_metadata.get(tool):
yield from tool_definition
@staticmethod
def _validate_function_choice_behavior(
function_choice_behavior: FunctionChoiceBehavior | None,
) -> None:
"""Validate the function choice behavior is compatible with agent invocations."""
if function_choice_behavior is None:
return
if function_choice_behavior.type_ != FunctionChoiceType.AUTO:
raise AgentInvokeException(
f"FunctionChoiceBehavior with type '{function_choice_behavior.type_}' is not supported for agent "
"invocations. Use FunctionChoiceBehavior.Auto(filters=...) to control which kernel functions "
"are available."
)
if not function_choice_behavior.auto_invoke_kernel_functions:
raise AgentInvokeException(
"FunctionChoiceBehavior.Auto(auto_invoke=False) is not supported for agent invocations. "
"The agent run loop manages tool invocation; disabling auto_invoke is not compatible."
)
valid_filter_keys: set[str] = {
"excluded_plugins",
"included_plugins",
"excluded_functions",
"included_functions",
}
if function_choice_behavior.filters is not None:
if not function_choice_behavior.filters:
raise AgentInvokeException(
"FunctionChoiceBehavior filters must not be empty. Provide at least one filter key "
f"from {sorted(valid_filter_keys)}, or omit filters entirely to include all "
"kernel functions."
)
unknown_keys = {str(k) for k in function_choice_behavior.filters} - valid_filter_keys
if unknown_keys:
raise AgentInvokeException(
f"Unknown filter key(s): {sorted(unknown_keys)}. "
f"Valid filter keys are: {sorted(valid_filter_keys)}."
)
@classmethod
def _get_tools(
cls: type[_T],
agent: "OpenAIAssistantAgent",
kernel: "Kernel",
tools_override: "list[AssistantToolParam] | None" = None,
function_choice_behavior: FunctionChoiceBehavior | None = None,
) -> list[dict[str, str]]:
"""Get the list of tools for the assistant.
Args:
agent: The assistant agent.
kernel: The kernel to use for function metadata.
tools_override: When provided, overrides agent.definition.tools (SDK-level tools only).
function_choice_behavior: When provided, filters which kernel functions are included.
Returns:
The list of tools.
"""
tools: list[Any] = []
source_tools = tools_override if tools_override is not None else agent.definition.tools
for tool in source_tools:
if isinstance(tool, CodeInterpreterTool):
tools.append({"type": "code_interpreter"})
elif isinstance(tool, FileSearchTool):
tools.append({"type": "file_search"})
# Determine kernel function metadata based on function_choice_behavior
if function_choice_behavior is not None and not function_choice_behavior.enable_kernel_functions:
funcs = []
elif function_choice_behavior is not None and function_choice_behavior.filters:
funcs = kernel.get_list_of_function_metadata(function_choice_behavior.filters)
else:
funcs = kernel.get_full_list_of_function_metadata()
tools.extend([kernel_function_metadata_to_function_call_format(f) for f in funcs])
return tools
@@ -0,0 +1,286 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from collections.abc import Awaitable, Callable
from copy import copy
from typing import TYPE_CHECKING, Any
from azure.core.credentials import TokenCredential
from openai import AsyncAzureOpenAI
from pydantic import ValidationError
from semantic_kernel.agents import OpenAIAssistantAgent
from semantic_kernel.agents.agent import register_agent_type
from semantic_kernel.connectors.ai.open_ai.settings.azure_open_ai_settings import AzureOpenAISettings
from semantic_kernel.exceptions.agent_exceptions import AgentInitializationException
from semantic_kernel.utils.authentication.entra_id_authentication import get_entra_auth_token
from semantic_kernel.utils.feature_stage_decorator import release_candidate
from semantic_kernel.utils.telemetry.user_agent import APP_INFO, prepend_semantic_kernel_to_user_agent
if TYPE_CHECKING:
from semantic_kernel.kernel_pydantic import KernelBaseSettings
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 < "3.11":
from typing_extensions import Self # pragma: no cover
else:
from typing import Self # type: ignore # pragma: no cover
if sys.version_info >= (3, 13):
from warnings import deprecated
else:
from typing_extensions import deprecated
@release_candidate
@register_agent_type("azure_assistant")
class AzureAssistantAgent(OpenAIAssistantAgent):
"""An Azure Assistant Agent class that extends the OpenAI Assistant Agent class."""
@staticmethod
@deprecated(
"setup_resources is deprecated. Use AzureAssistantAgent.create_client() instead. This method will be removed by 2025-06-15." # noqa: E501
)
def setup_resources(
*,
ad_token: str | None = None,
ad_token_provider: Callable[[], str | Awaitable[str]] | None = None,
api_key: str | None = None,
api_version: str | None = None,
base_url: str | None = None,
default_headers: dict[str, str] | None = None,
deployment_name: str | None = None,
endpoint: str | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
token_scope: str | None = None,
credential: TokenCredential | None = None,
**kwargs: Any,
) -> tuple[AsyncAzureOpenAI, str]:
"""A method to create the Azure OpenAI client and the deployment name/model from the provided arguments.
Any arguments provided will override the values in the environment variables/environment file.
Args:
ad_token: The Microsoft Entra (previously Azure AD) token represented as a string
ad_token_provider: The Microsoft Entra (previously Azure AD) token provider provided as a callback
api_key: The API key
api_version: The API version
base_url: The base URL in the form https://<resource>.azure.openai.com/openai/deployments/<deployment_name>
default_headers: The default headers to add to the client
deployment_name: The deployment name
endpoint: The endpoint in the form https://<resource>.azure.openai.com
env_file_path: The environment file path
env_file_encoding: The environment file encoding, defaults to utf-8
token_scope: The token scope
credential: The credential to use for authentication.
kwargs: Additional keyword arguments
Returns:
An Azure OpenAI client instance and the configured deployment name (model)
"""
try:
azure_openai_settings = AzureOpenAISettings(
api_key=api_key,
base_url=base_url,
endpoint=endpoint,
chat_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_scope,
)
except ValidationError as exc:
raise AgentInitializationException(f"Failed to create Azure OpenAI settings: {exc}") from exc
if (
azure_openai_settings.api_key is None
and ad_token_provider is None
and ad_token is None
and azure_openai_settings.token_endpoint
and credential
):
ad_token = get_entra_auth_token(credential, azure_openai_settings.token_endpoint)
# If we still have no credentials, we can't proceed
if not azure_openai_settings.api_key and not ad_token and not ad_token_provider and not credential:
raise AgentInitializationException(
"Please provide either an api_key, ad_token, ad_token_provider or credential for authentication."
)
merged_headers = dict(copy(default_headers)) if default_headers else {}
if default_headers:
merged_headers.update(default_headers)
if APP_INFO:
merged_headers.update(APP_INFO)
merged_headers = prepend_semantic_kernel_to_user_agent(merged_headers)
if not azure_openai_settings.endpoint:
raise AgentInitializationException("Please provide an Azure OpenAI endpoint")
if not azure_openai_settings.chat_deployment_name:
raise AgentInitializationException("Please provide an Azure OpenAI deployment name")
client = AsyncAzureOpenAI(
azure_endpoint=str(azure_openai_settings.endpoint),
api_version=azure_openai_settings.api_version,
api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None,
azure_ad_token=ad_token,
azure_ad_token_provider=ad_token_provider,
default_headers=merged_headers,
**kwargs,
)
return client, azure_openai_settings.chat_deployment_name
@staticmethod
def create_client(
*,
ad_token: str | None = None,
ad_token_provider: Callable[[], str | Awaitable[str]] | None = None,
api_key: str | None = None,
api_version: str | None = None,
base_url: str | None = None,
default_headers: dict[str, str] | None = None,
deployment_name: str | None = None,
endpoint: str | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
token_scope: str | None = None,
credential: TokenCredential | None = None,
**kwargs: Any,
) -> AsyncAzureOpenAI:
"""A method to create the Azure OpenAI client.
Any arguments provided will override the values in the environment variables/environment file.
Args:
ad_token: The Microsoft Entra (previously Azure AD) token represented as a string
ad_token_provider: The Microsoft Entra (previously Azure AD) token provider provided as a callback
api_key: The API key
api_version: The API version
base_url: The base URL in the form https://<resource>.azure.openai.com/openai/deployments/<deployment_name>
default_headers: The default headers to add to the client
deployment_name: The deployment name
endpoint: The endpoint in the form https://<resource>.azure.openai.com
env_file_path: The environment file path
env_file_encoding: The environment file encoding, defaults to utf-8
token_scope: The token scope
credential: The credential to use for authentication.
kwargs: Additional keyword arguments
Returns:
An Azure OpenAI client instance.
"""
try:
azure_openai_settings = AzureOpenAISettings(
api_key=api_key,
base_url=base_url,
endpoint=endpoint,
chat_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_scope,
)
except ValidationError as exc:
raise AgentInitializationException(f"Failed to create Azure OpenAI settings: {exc}") from exc
if (
azure_openai_settings.api_key is None
and ad_token_provider is None
and ad_token is None
and azure_openai_settings.token_endpoint
and credential
):
ad_token = get_entra_auth_token(credential, azure_openai_settings.token_endpoint)
# If we still have no credentials, we can't proceed
if not azure_openai_settings.api_key and not ad_token and not ad_token_provider and not credential:
raise AgentInitializationException(
"Please provide either an api_key, ad_token, ad_token_provider or credential for authentication."
)
merged_headers = dict(copy(default_headers)) if default_headers else {}
if default_headers:
merged_headers.update(default_headers)
if APP_INFO:
merged_headers.update(APP_INFO)
merged_headers = prepend_semantic_kernel_to_user_agent(merged_headers)
if not azure_openai_settings.endpoint:
raise AgentInitializationException("Please provide an Azure OpenAI endpoint")
if not azure_openai_settings.chat_deployment_name:
raise AgentInitializationException("Please provide an Azure OpenAI deployment name")
return AsyncAzureOpenAI(
azure_endpoint=str(azure_openai_settings.endpoint),
api_version=azure_openai_settings.api_version,
api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None,
azure_ad_token=ad_token,
azure_ad_token_provider=ad_token_provider,
default_headers=merged_headers,
**kwargs,
)
@override
@classmethod
def resolve_placeholders(
cls: type[Self],
yaml_str: str,
settings: "KernelBaseSettings | None" = None,
extras: dict[str, Any] | None = None,
) -> str:
"""Substitute ${AzureOpenAI:Key} placeholders with fields from AzureOpenAIAgentSettings and extras."""
import re
pattern = re.compile(r"\$\{([^}]+)\}")
# Build the mapping only if settings is provided and valid
field_mapping: dict[str, Any] = {}
if settings is None:
settings = AzureOpenAISettings()
if not isinstance(settings, AzureOpenAISettings):
raise AgentInitializationException(f"Expected AzureOpenAISettings, got {type(settings).__name__}")
field_mapping.update({
"ChatModelId": cls._get_setting(getattr(settings, "chat_deployment_name", None)),
"AgentId": cls._get_setting(getattr(settings, "agent_id", None)),
"ApiKey": cls._get_setting(getattr(settings, "api_key", None)),
"ApiVersion": cls._get_setting(getattr(settings, "api_version", None)),
"BaseUrl": cls._get_setting(getattr(settings, "base_url", None)),
"Endpoint": cls._get_setting(getattr(settings, "endpoint", None)),
"TokenEndpoint": cls._get_setting(getattr(settings, "token_endpoint", None)),
})
if extras:
field_mapping.update(extras)
def replacer(match: re.Match[str]) -> str:
"""Replace the matched placeholder with the corresponding value from field_mapping."""
full_key = match.group(1) # for example, OpenAI:ApiKey
section, _, key = full_key.partition(":")
if section != "AzureOpenAI":
return match.group(0)
# Try short key first (ApiKey), then full (OpenAI:ApiKey)
return str(field_mapping.get(key) or field_mapping.get(full_key) or match.group(0))
result = pattern.sub(replacer, yaml_str)
# Safety check for unresolved placeholders
unresolved = pattern.findall(result)
if unresolved:
raise AgentInitializationException(
f"Unresolved placeholders in spec: {', '.join(f'${{{key}}}' for key in unresolved)}"
)
return result
@@ -0,0 +1,293 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import sys
from collections.abc import Awaitable, Callable
from copy import copy
from typing import TYPE_CHECKING, Any
from azure.core.credentials import TokenCredential
from openai import AsyncAzureOpenAI
from pydantic import ValidationError
from semantic_kernel.agents import OpenAIResponsesAgent
from semantic_kernel.agents.agent import register_agent_type
from semantic_kernel.connectors.ai.open_ai.settings.azure_open_ai_settings import AzureOpenAISettings
from semantic_kernel.exceptions.agent_exceptions import (
AgentInitializationException,
)
from semantic_kernel.utils.authentication.entra_id_authentication import get_entra_auth_token
from semantic_kernel.utils.feature_stage_decorator import experimental
from semantic_kernel.utils.telemetry.user_agent import APP_INFO, prepend_semantic_kernel_to_user_agent
if TYPE_CHECKING:
from semantic_kernel.kernel_pydantic import KernelBaseSettings
logger: logging.Logger = logging.getLogger(__name__)
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 < "3.11":
from typing_extensions import Self # pragma: no cover
else:
from typing import Self # type: ignore # pragma: no cover
if sys.version_info >= (3, 13):
from warnings import deprecated
else:
from typing_extensions import deprecated
@experimental
@register_agent_type("azure_responses")
class AzureResponsesAgent(OpenAIResponsesAgent):
"""Azure Responses Agent class.
Provides the ability to interact with Azure's Responses API.
"""
@staticmethod
@deprecated(
"setup_resources is deprecated. Use AzureResponsesAgent.create_client() instead. This method will be removed by 2025-06-15." # noqa: E501
)
def setup_resources(
*,
ad_token: str | None = None,
ad_token_provider: Callable[[], str | Awaitable[str]] | None = None,
api_key: str | None = None,
api_version: str | None = None,
base_url: str | None = None,
default_headers: dict[str, str] | None = None,
deployment_name: str | None = None,
endpoint: str | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
token_scope: str | None = None,
credential: TokenCredential | None = None,
**kwargs: Any,
) -> tuple[AsyncAzureOpenAI, str]:
"""A method to create the Azure OpenAI client and the deployment name/model from the provided arguments.
Any arguments provided will override the values in the environment variables/environment file.
Args:
ad_token: The Microsoft Entra (previously Azure AD) token represented as a string
ad_token_provider: The Microsoft Entra (previously Azure AD) token provider provided as a callback
api_key: The API key
api_version: The API version
base_url: The base URL in the form https://<resource>.azure.openai.com/openai/deployments/<deployment_name>
default_headers: The default headers to add to the client
deployment_name: The Responses deployment name
endpoint: The endpoint in the form https://<resource>.azure.openai.com
env_file_path: The environment file path
env_file_encoding: The environment file encoding, defaults to utf-8
token_scope: The token scope
credential: The credential to use for authentication.
kwargs: Additional keyword arguments
Returns:
An Azure OpenAI client instance and the configured deployment name (model)
"""
try:
azure_openai_settings = AzureOpenAISettings(
api_key=api_key,
base_url=base_url,
endpoint=endpoint,
responses_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_scope,
)
except ValidationError as exc:
raise AgentInitializationException(f"Failed to create Azure OpenAI settings: {exc}") from exc
if (
azure_openai_settings.api_key is None
and ad_token_provider is None
and ad_token is None
and azure_openai_settings.token_endpoint
and credential
):
ad_token = get_entra_auth_token(credential, azure_openai_settings.token_endpoint)
# If we still have no credentials, we can't proceed
if not azure_openai_settings.api_key and not ad_token and not ad_token_provider and not credential:
raise AgentInitializationException(
"Please provide either an api_key, ad_token, ad_token_provider or credential for authentication."
)
merged_headers = dict(copy(default_headers)) if default_headers else {}
if default_headers:
merged_headers.update(default_headers)
if APP_INFO:
merged_headers.update(APP_INFO)
merged_headers = prepend_semantic_kernel_to_user_agent(merged_headers)
if not azure_openai_settings.endpoint:
raise AgentInitializationException("Please provide an Azure OpenAI endpoint")
if not azure_openai_settings.responses_deployment_name:
raise AgentInitializationException("Please provide an Azure OpenAI Responses deployment name")
client = AsyncAzureOpenAI(
azure_endpoint=str(azure_openai_settings.endpoint),
api_version=azure_openai_settings.api_version,
api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None,
azure_ad_token=ad_token,
azure_ad_token_provider=ad_token_provider,
default_headers=merged_headers,
**kwargs,
)
return client, azure_openai_settings.responses_deployment_name
@staticmethod
def create_client(
*,
ad_token: str | None = None,
ad_token_provider: Callable[[], str | Awaitable[str]] | None = None,
api_key: str | None = None,
api_version: str | None = None,
base_url: str | None = None,
default_headers: dict[str, str] | None = None,
deployment_name: str | None = None,
endpoint: str | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
token_scope: str | None = None,
credential: TokenCredential | None = None,
**kwargs: Any,
) -> AsyncAzureOpenAI:
"""A method to create the Azure OpenAI client.
Any arguments provided will override the values in the environment variables/environment file.
Args:
ad_token: The Microsoft Entra (previously Azure AD) token represented as a string
ad_token_provider: The Microsoft Entra (previously Azure AD) token provider provided as a callback
api_key: The API key
api_version: The API version
base_url: The base URL in the form https://<resource>.azure.openai.com/openai/deployments/<deployment_name>
default_headers: The default headers to add to the client
deployment_name: The Responses deployment name
endpoint: The endpoint in the form https://<resource>.azure.openai.com
env_file_path: The environment file path
env_file_encoding: The environment file encoding, defaults to utf-8
token_scope: The token scope
credential: The credential to use for authentication.
kwargs: Additional keyword arguments
Returns:
An Azure OpenAI client instance.
"""
try:
azure_openai_settings = AzureOpenAISettings(
api_key=api_key,
base_url=base_url,
endpoint=endpoint,
responses_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_scope,
)
except ValidationError as exc:
raise AgentInitializationException(f"Failed to create Azure OpenAI settings: {exc}") from exc
if (
azure_openai_settings.api_key is None
and ad_token_provider is None
and ad_token is None
and azure_openai_settings.token_endpoint
and credential
):
ad_token = get_entra_auth_token(credential, azure_openai_settings.token_endpoint)
# If we still have no credentials, we can't proceed
if not azure_openai_settings.api_key and not ad_token and not ad_token_provider and not credential:
raise AgentInitializationException(
"Please provide either an api_key, ad_token, ad_token_provider or credential for authentication."
)
merged_headers = dict(copy(default_headers)) if default_headers else {}
if default_headers:
merged_headers.update(default_headers)
if APP_INFO:
merged_headers.update(APP_INFO)
merged_headers = prepend_semantic_kernel_to_user_agent(merged_headers)
if not azure_openai_settings.endpoint:
raise AgentInitializationException("Please provide an Azure OpenAI endpoint")
if not azure_openai_settings.responses_deployment_name:
raise AgentInitializationException("Please provide an Azure OpenAI Responses deployment name")
return AsyncAzureOpenAI(
azure_endpoint=str(azure_openai_settings.endpoint),
api_version=azure_openai_settings.api_version,
api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None,
azure_ad_token=ad_token,
azure_ad_token_provider=ad_token_provider,
default_headers=merged_headers,
**kwargs,
)
@override
@classmethod
def resolve_placeholders(
cls: type[Self],
yaml_str: str,
settings: "KernelBaseSettings | None" = None,
extras: dict[str, Any] | None = None,
) -> str:
"""Substitute ${AzureOpenAI:Key} placeholders with fields from AzureOpenAIAgentSettings and extras."""
import re
pattern = re.compile(r"\$\{([^}]+)\}")
# Build the mapping only if settings is provided and valid
field_mapping: dict[str, Any] = {}
if settings is None:
settings = AzureOpenAISettings()
if not isinstance(settings, AzureOpenAISettings):
raise AgentInitializationException(f"Expected AzureOpenAISettings, got {type(settings).__name__}")
field_mapping.update({
"ChatModelId": getattr(settings, "responses_deployment_name", None),
"AgentId": getattr(settings, "agent_id", None),
"ApiKey": getattr(settings, "api_key", None),
"ApiVersion": getattr(settings, "api_version", None),
"BaseUrl": getattr(settings, "base_url", None),
"Endpoint": getattr(settings, "endpoint", None),
"TokenEndpoint": getattr(settings, "token_endpoint", None),
})
if extras:
field_mapping.update(extras)
def replacer(match: re.Match[str]) -> str:
"""Replace the matched placeholder with the corresponding value from field_mapping."""
full_key = match.group(1) # for example, AzureOpenAI:ApiKey
section, _, key = full_key.partition(":")
if section != "AzureOpenAI":
return match.group(0)
# Try short key first (ApiKey), then full (AzureOpenAI:ApiKey)
return str(field_mapping.get(key) or field_mapping.get(full_key) or match.group(0))
result = pattern.sub(replacer, yaml_str)
# Safety check for unresolved placeholders
unresolved = pattern.findall(result)
if unresolved:
raise AgentInitializationException(
f"Unresolved placeholders in spec: {', '.join(f'${{{key}}}' for key in unresolved)}"
)
return result
@@ -0,0 +1,19 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from dataclasses import dataclass
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
from semantic_kernel.utils.feature_stage_decorator import experimental
logger: logging.Logger = logging.getLogger(__name__)
@experimental
@dataclass
class FunctionActionResult:
"""Function Action Result."""
function_call_streaming_content: StreamingChatMessageContent
function_result_streaming_content: StreamingChatMessageContent
tool_outputs: list[dict[str, str]]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
# Copyright (c) Microsoft. All rights reserved.
from datetime import timedelta
from pydantic import Field
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class RunPollingOptions(KernelBaseModel):
"""Configuration and defaults associated with polling behavior for Assistant API requests."""
default_polling_interval: timedelta = Field(default=timedelta(milliseconds=250))
default_polling_backoff: timedelta = Field(default=timedelta(seconds=1))
default_polling_backoff_threshold: int = Field(default=2)
default_message_synchronization_delay: timedelta = Field(default=timedelta(milliseconds=250))
run_polling_interval: timedelta = Field(default=timedelta(milliseconds=250))
run_polling_backoff: timedelta = Field(default=timedelta(seconds=1))
run_polling_backoff_threshold: int = Field(default=2)
message_synchronization_delay: timedelta = Field(default=timedelta(milliseconds=250))
run_polling_timeout: timedelta = Field(default=timedelta(minutes=1)) # New timeout attribute
def get_polling_interval(self, iteration_count: int) -> timedelta:
"""Get the polling interval for the given iteration count."""
return (
self.run_polling_backoff
if iteration_count > self.run_polling_backoff_threshold
else self.run_polling_interval
)
@@ -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
@@ -0,0 +1,33 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.agents.runtime.core.agent import Agent
from semantic_kernel.agents.runtime.core.agent_id import AgentId, CoreAgentId
from semantic_kernel.agents.runtime.core.agent_metadata import AgentMetadata, CoreAgentMetadata
from semantic_kernel.agents.runtime.core.base_agent import BaseAgent
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 MessageHandler, RoutedAgent, message_handler
from semantic_kernel.agents.runtime.core.subscription import Subscription
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.agents.runtime.in_process.default_subscription import DefaultSubscription
from semantic_kernel.agents.runtime.in_process.in_process_runtime import InProcessRuntime
from semantic_kernel.agents.runtime.in_process.type_subscription import TypeSubscription
__all__ = [
"Agent",
"AgentId",
"AgentMetadata",
"BaseAgent",
"CoreAgentId",
"CoreAgentMetadata",
"CoreRuntime",
"DefaultSubscription",
"InProcessRuntime",
"MessageContext",
"MessageHandler",
"RoutedAgent",
"Subscription",
"TopicId",
"TypeSubscription",
"message_handler",
]
@@ -0,0 +1,18 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.agents.runtime.core.agent_id import AgentId, CoreAgentId
from semantic_kernel.agents.runtime.core.agent_metadata import AgentMetadata, CoreAgentMetadata
from semantic_kernel.agents.runtime.core.agent_type import AgentType, CoreAgentType
from semantic_kernel.agents.runtime.core.base_agent import BaseAgent
from semantic_kernel.agents.runtime.core.core_runtime import CoreRuntime
__all__ = [
"AgentId",
"AgentMetadata",
"AgentType",
"BaseAgent",
"CoreAgentId",
"CoreAgentMetadata",
"CoreAgentType",
"CoreRuntime",
]
@@ -0,0 +1,57 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Mapping
from typing import Any, Protocol, runtime_checkable
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.agent_metadata import AgentMetadata
from semantic_kernel.agents.runtime.core.message_context import MessageContext
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
@runtime_checkable
class Agent(Protocol):
"""Protocol for an agent."""
@property
def metadata(self) -> AgentMetadata:
"""Metadata of the agent."""
...
@property
def id(self) -> AgentId:
"""ID of the agent."""
...
async def on_message(self, message: Any, ctx: MessageContext) -> Any:
"""Message handler for the agent. This should only be called by the runtime, not by other agents.
Args:
message (Any): Received message. Type is one of the types in `subscriptions`.
ctx (MessageContext): Context of the message.
Returns:
Any: Response to the message. Can be None.
Raises:
asyncio.CancelledError: If the message was cancelled.
CantHandleException: If the agent cannot handle the message.
"""
...
async def save_state(self) -> Mapping[str, Any]:
"""Save the state of the agent. The result must be JSON serializable."""
...
async def load_state(self, state: Mapping[str, Any]) -> None:
"""Load in the state of the agent obtained from `save_state`.
Args:
state (Mapping[str, Any]): State of the agent. Must be JSON serializable.
"""
...
async def close(self) -> None:
"""Called when the runtime is closed."""
...
@@ -0,0 +1,102 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from typing import Protocol, runtime_checkable
from semantic_kernel.utils.feature_stage_decorator import experimental
if sys.version < "3.11":
from typing_extensions import Self # pragma: no cover
else:
from typing import Self # type: ignore # pragma: no cover
from semantic_kernel.agents.runtime.core.agent_type import AgentType
from semantic_kernel.agents.runtime.core.validation_utils import is_valid_agent_type
@experimental
@runtime_checkable
class AgentId(Protocol):
"""Defines the minimal interface an AgentId.
It must fulfill a 'type' and a 'key' that identify the agent instance.
"""
@property
def type(self) -> str:
"""Defines the 'type' or category of the agent."""
...
@property
def key(self) -> str:
"""Defines the unique instance key within the agent type."""
...
def __eq__(self, other: object) -> bool:
"""Equality check must differentiate between different IDs."""
...
def __hash__(self) -> int:
"""Hash value needed to store AgentIds in sets/dicts."""
...
def __str__(self) -> str:
"""String representation of the AgentId, e.g. 'type/key'."""
...
@experimental
class CoreAgentId(AgentId):
"""Core implementation of the AgentId protocol."""
def __init__(self, type: str | AgentType, key: str) -> None:
"""Initialize the AgentId with the given type and key."""
# If `type` is itself an AgentType, extract the string property.
if isinstance(type, AgentType):
type = type.type
if not is_valid_agent_type(type):
raise ValueError(
rf"Invalid agent type: {type}. "
r"Allowed values MUST match the regex: `^[\w\-\.]+\Z`"
)
self._type = type
self._key = key
@classmethod
def from_str(cls, agent_id: str) -> Self:
"""Convert a string of the format ``type/key`` into a CoreAgentId."""
items = agent_id.split("/", maxsplit=1)
if len(items) != 2:
raise ValueError(f"Invalid agent id: {agent_id}")
t, k = items[0], items[1]
return cls(t, k)
@property
def type(self) -> str:
r"""The agent's 'type' (or category). Must match `^[\\w\\-\\.]+$`."""
return self._type
@property
def key(self) -> str:
"""The agent's instance key, e.g. 'default' or a unique identifier."""
return self._key
def __eq__(self, value: object) -> bool:
"""Check if two AgentIds are equal by comparing 'type' and 'key'."""
if not isinstance(value, AgentId):
return False
return (self.type == value.type) and (self.key == value.key)
def __hash__(self) -> int:
"""Generate a hash so we can store AgentIds in sets/dicts."""
return hash((self._type, self._key))
def __str__(self) -> str:
"""Convert the AgentId to a user-friendly string."""
return f"{self._type}/{self._key}"
def __repr__(self) -> str:
"""Generate a detailed string representation."""
return f'CoreAgentId(type="{self._type}", key="{self._key}")'
@@ -0,0 +1,56 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Protocol, runtime_checkable
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
@runtime_checkable
class AgentMetadata(Protocol):
"""Provides a description for an agent: type, key, and an optional 'description' field."""
@property
def type(self) -> str:
"""Defines the 'type' or category of the agent."""
...
@property
def key(self) -> str:
"""Defines the 'key' or identifier of the agent."""
...
@property
def description(self) -> str:
"""Defines the 'description' of the agent."""
...
@experimental
class CoreAgentMetadata(AgentMetadata):
"""Concrete immutable implementation of AgentMetadata."""
_type: str
_key: str
_description: str
def __init__(self, type: str, key: str, description: str = "") -> None:
"""Initialize the agent metadata."""
self._type = type
self._key = key
self._description = description
@property
def type(self) -> str:
"""Defines the 'type' or category of the agent."""
return self._type
@property
def key(self) -> str:
"""Defines the 'key' or identifier of the agent."""
return self._key
@property
def description(self) -> str:
"""Defines the 'description' of the agent."""
return self._description
@@ -0,0 +1,35 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
@runtime_checkable
class AgentType(Protocol):
"""Defines the minimal interface an AgentType."""
@property
def type(self) -> str:
"""Defines the 'type' or category of the agent."""
...
@experimental
@dataclass(eq=True, frozen=True)
class CoreAgentType:
"""Concrete immutable implementation of AgentType."""
_type: str
@property
def type(self) -> str:
"""Defines the 'type' or category of the agent."""
return self._type
def __str__(self) -> str:
"""Return the string representation of the agent type."""
return self._type
@@ -0,0 +1,221 @@
# Copyright (c) Microsoft. All rights reserved.
import inspect
import warnings
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any, ClassVar, TypeVar, final
from typing_extensions import Self
from semantic_kernel.agents.runtime.core.agent import Agent
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.agent_metadata import AgentMetadata, CoreAgentMetadata
from semantic_kernel.agents.runtime.core.agent_type import AgentType, CoreAgentType
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.serialization import MessageSerializer, try_get_known_serializers_for_type
from semantic_kernel.agents.runtime.core.subscription import Subscription, UnboundSubscription
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.agents.runtime.in_process.agent_instantiation_context import AgentInstantiationContext
from semantic_kernel.agents.runtime.in_process.subscription_context import SubscriptionInstantiationContext
from semantic_kernel.agents.runtime.in_process.type_prefix_subscription import TypePrefixSubscription
from semantic_kernel.utils.feature_stage_decorator import experimental
T = TypeVar("T", bound=Agent)
BaseAgentType = TypeVar("BaseAgentType", bound="BaseAgent")
# Decorator for adding an unbound subscription to an agent
@experimental
def subscription_factory(subscription: UnboundSubscription) -> Callable[[type[BaseAgentType]], type[BaseAgentType]]:
"""Decorator for adding an unbound subscription to an agent."""
def decorator(cls: type[BaseAgentType]) -> type[BaseAgentType]:
cls.internal_unbound_subscriptions_list.append(subscription)
return cls
return decorator
@experimental
def handles(
msg_type: type[Any], serializer: MessageSerializer[Any] | list[MessageSerializer[Any]] | None = None
) -> Callable[[type[BaseAgentType]], type[BaseAgentType]]:
"""Decorator for associating a message type and corresponding serializer(s) with a BaseAgent or its subclass."""
def decorator(cls: type[BaseAgentType]) -> type[BaseAgentType]:
if serializer is None:
serializer_list = try_get_known_serializers_for_type(msg_type)
else:
serializer_list = [serializer] if not isinstance(serializer, Sequence) else list(serializer)
if not serializer_list:
raise ValueError(f"No serializers found for type {msg_type!r}. Please provide an explicit serializer.")
cls.internal_extra_handles_types.append((msg_type, serializer_list))
return cls
return decorator
@experimental
class BaseAgent(ABC, Agent):
"""Base class for all agents."""
internal_unbound_subscriptions_list: ClassVar[list[UnboundSubscription]] = []
""":meta private:"""
internal_extra_handles_types: ClassVar[list[tuple[type[Any], list[MessageSerializer[Any]]]]] = []
""":meta private:"""
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Initialize the class."""
super().__init_subclass__(**kwargs)
# Automatically set class_variable in each subclass so that they are not shared between subclasses
cls.internal_extra_handles_types = []
cls.internal_unbound_subscriptions_list = []
@classmethod
def _handles_types(cls) -> list[tuple[type[Any], list[MessageSerializer[Any]]]]:
return cls.internal_extra_handles_types
@classmethod
def _unbound_subscriptions(cls) -> list[UnboundSubscription]:
return cls.internal_unbound_subscriptions_list
@property
def metadata(self) -> AgentMetadata:
"""Get the metadata for this agent."""
assert self._id is not None # nosec
return CoreAgentMetadata(key=self._id.key, type=self._id.type, description=self._description)
def __init__(self, description: str) -> None:
"""Initialize the agent."""
try:
runtime = AgentInstantiationContext.current_runtime()
id = AgentInstantiationContext.current_agent_id()
except LookupError as e:
raise RuntimeError(
"BaseAgent must be instantiated within the context of an AgentRuntime. It cannot be directly "
"instantiated."
) from e
self._runtime: CoreRuntime = runtime
self._id: AgentId = id
if not isinstance(description, str):
raise ValueError("Agent description must be a string")
self._description = description
@property
def type(self) -> str:
"""Get the type of the agent."""
return self.id.type
@property
def id(self) -> AgentId:
"""Get the id of the agent."""
return self._id
@property
def runtime(self) -> CoreRuntime:
"""Get the runtime of the agent."""
return self._runtime
@final
async def on_message(self, message: Any, ctx: MessageContext) -> Any:
"""Handle a message sent to this agent."""
return await self.on_message_impl(message, ctx)
@abstractmethod
async def on_message_impl(self, message: Any, ctx: MessageContext) -> Any:
"""Handle a message sent to this agent."""
...
async def send_message(
self,
message: Any,
recipient: AgentId,
*,
cancellation_token: CancellationToken | None = None,
message_id: str | None = None,
) -> Any:
"""Send a message to another agent."""
if cancellation_token is None:
cancellation_token = CancellationToken()
return await self._runtime.send_message(
message,
sender=self.id,
recipient=recipient,
cancellation_token=cancellation_token,
message_id=message_id,
)
async def publish_message(
self,
message: Any,
topic_id: TopicId,
*,
cancellation_token: CancellationToken | None = None,
) -> None:
"""Publish a message."""
await self._runtime.publish_message(message, topic_id, sender=self.id, cancellation_token=cancellation_token)
async def save_state(self) -> Mapping[str, Any]:
"""Save the state of the agent."""
warnings.warn("save_state not implemented", stacklevel=2)
return {}
async def load_state(self, state: Mapping[str, Any]) -> None:
"""Load the state of the agent."""
warnings.warn("load_state not implemented", stacklevel=2)
pass
async def close(self) -> None:
"""Close the agent."""
pass
@classmethod
async def register(
cls,
runtime: CoreRuntime,
type: str,
factory: Callable[[], Self | Awaitable[Self]],
*,
skip_class_subscriptions: bool = False,
skip_direct_message_subscription: bool = False,
) -> AgentType:
"""Register the agent with the runtime."""
agent_type = CoreAgentType(type)
agent_type = await runtime.register_factory(type=agent_type, agent_factory=factory, expected_class=cls) # type: ignore
if not skip_class_subscriptions:
with SubscriptionInstantiationContext.populate_context(agent_type):
subscriptions: list[Subscription] = []
for unbound_subscription in cls._unbound_subscriptions():
subscriptions_list_result = unbound_subscription()
if inspect.isawaitable(subscriptions_list_result):
subscriptions_list = await subscriptions_list_result
else:
subscriptions_list = subscriptions_list_result
subscriptions.extend(subscriptions_list)
for subscription in subscriptions:
await runtime.add_subscription(subscription)
if not skip_direct_message_subscription:
# Additionally adds a special prefix subscription for this agent to receive direct messages
await runtime.add_subscription(
TypePrefixSubscription(
# The prefix MUST include ":" to avoid collisions with other agents
topic_type_prefix=agent_type.type + ":",
agent_type=agent_type.type,
)
)
# TODO(evmattso): deduplication
for _message_type, serializer in cls._handles_types():
runtime.add_message_serializer(serializer)
return agent_type
@@ -0,0 +1,53 @@
# Copyright (c) Microsoft. All rights reserved.
import threading
from asyncio import Future
from collections.abc import Callable
from typing import Any
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class CancellationToken:
"""A token used to cancel pending async calls."""
def __init__(self) -> None:
"""Initialize the CancellationToken."""
self._cancelled: bool = False
self._lock: threading.Lock = threading.Lock()
self._callbacks: list[Callable[[], None]] = []
def cancel(self) -> None:
"""Cancel pending async calls linked to this cancellation token."""
with self._lock:
if not self._cancelled:
self._cancelled = True
for callback in self._callbacks:
callback()
def is_cancelled(self) -> bool:
"""Check if the CancellationToken has been used."""
with self._lock:
return self._cancelled
def add_callback(self, callback: Callable[[], None]) -> None:
"""Attach a callback that will be called when cancel is invoked."""
with self._lock:
if self._cancelled:
callback()
else:
self._callbacks.append(callback)
def link_future(self, future: Future[Any]) -> Future[Any]:
"""Link a pending async call to a token to allow its cancellation."""
with self._lock:
if self._cancelled:
future.cancel()
else:
def _cancel() -> None:
future.cancel()
self._callbacks.append(_cancel)
return future
@@ -0,0 +1,204 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any, Protocol, TypeVar, overload, runtime_checkable
from semantic_kernel.agents.runtime.core.agent import Agent
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.agent_metadata import AgentMetadata
from semantic_kernel.agents.runtime.core.agent_type import AgentType
from semantic_kernel.agents.runtime.core.cancellation_token import CancellationToken
from semantic_kernel.agents.runtime.core.serialization import MessageSerializer
from semantic_kernel.agents.runtime.core.subscription import Subscription
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.utils.feature_stage_decorator import experimental
# Undeliverable - error
T = TypeVar("T", bound=Agent)
@experimental
@runtime_checkable
class CoreRuntime(Protocol):
"""CoreRuntime is the main entry point for the agent runtime.
It is responsible for managing agents and their interactions.
"""
async def send_message(
self,
message: Any,
recipient: AgentId,
*,
sender: AgentId | None = None,
cancellation_token: CancellationToken | None = None,
message_id: str | None = None,
) -> Any:
"""Send a message to an agent and get a response.
Args:
message (Any): The message to send.
recipient (AgentId): The agent to send the message to.
sender (AgentId | None, optional): Agent which sent the message. Should **only** be None if this was sent
from no agent, such as directly to the runtime externally. Defaults to None.
cancellation_token (CancellationToken | None, optional): Token used to cancel an in progress.
Defaults to None.
message_id (str | None, optional): The message id. If None, a new message id will be generated.
Raises:
CantHandleException: If the recipient cannot handle the message.
UndeliverableException: If the message cannot be delivered.
Other: Any other exception raised by the recipient.
Returns:
Any: The response from the agent.
"""
...
async def publish_message(
self,
message: Any,
topic_id: TopicId,
*,
sender: AgentId | None = None,
cancellation_token: CancellationToken | None = None,
message_id: str | None = None,
) -> None:
"""Publish a message to all agents in the given namespace.
If no namespace is provided, the namespace of the sender. No responses are expected from publishing.
Args:
message (Any): The message to publish.
topic_id (TopicId): The topic to publish the message to.
sender (AgentId | None, optional): The agent which sent the message. Defaults to None.
cancellation_token (CancellationToken | None, optional): Token used to cancel an in progress.
Defaults to None.
message_id (str | None, optional): The message id. If None, a new message id will be generated.
Defaults to None. This message id must be unique. and is recommended to be a UUID.
Raises:
UndeliverableException: If the message cannot be delivered.
"""
...
async def register_factory(
self,
type: str | AgentType,
agent_factory: Callable[[], T | Awaitable[T]],
*,
expected_class: type[T] | None = None,
) -> AgentType:
"""Register an agent factory with the runtime associated with a specific type. The type must be unique.
This API does not add any subscriptions.
Args:
type (str): The type of agent this factory creates. It is not the same as agent class name.
The `type` parameter is used to differentiate between different factory functions rather than
agent classes.
agent_factory (Callable[[], T]): The factory that creates the agent, where T is a concrete Agent type.
Inside the factory, use `agent_runtime.AgentInstantiationContext` to access variables like the current
runtime and agent ID.
expected_class (type[T] | None, optional): The expected class of the agent, used for runtime validation
of the factory. Defaults to None. If None, no validation is performed.
"""
...
# TODO(evmattso): uncomment out the following type ignore when this is fixed in mypy: https://github.com/python/mypy/issues/3737
async def try_get_underlying_agent_instance(self, id: AgentId, type: type[T] = Agent) -> T: # type: ignore[assignment]
"""Try to get the underlying agent instance by name and namespace.
This is generally discouraged (hence the long name), but can be useful in some cases.
If the underlying agent is not accessible, this will raise an exception.
Args:
id (AgentId): The agent id.
type (Type[T], optional): The expected type of the agent. Defaults to Agent.
Returns:
T: The concrete agent instance.
Raises:
LookupError: If the agent is not found.
NotAccessibleError: If the agent is not accessible, for example if it is located remotely.
TypeError: If the agent is not of the expected type.
"""
...
@overload
async def get(self, id: AgentId, /, *, lazy: bool = ...) -> AgentId: ...
@overload
async def get(self, type: AgentType | str, /, key: str = ..., *, lazy: bool = ...) -> AgentId: ...
async def get(
self, id_or_type: AgentId | AgentType | str, /, key: str = "default", *, lazy: bool = True
) -> AgentId:
"""Get an agent by id or type."""
...
async def agent_metadata(self, agent: AgentId) -> AgentMetadata:
"""Get the metadata for an agent.
Args:
agent (AgentId): The agent id.
Returns:
AgentMetadata: The agent metadata.
"""
...
async def agent_save_state(self, agent: AgentId) -> Mapping[str, Any]:
"""Save the state of a single agent.
The structure of the state is implementation defined and can be any JSON serializable object.
Args:
agent (AgentId): The agent id.
Returns:
Mapping[str, Any]: The saved state.
"""
...
async def agent_load_state(self, agent: AgentId, state: Mapping[str, Any]) -> None:
"""Load the state of a single agent.
Args:
agent (AgentId): The agent id.
state (Mapping[str, Any]): The saved state.
"""
...
async def add_subscription(self, subscription: Subscription) -> None:
"""Add a new subscription that the runtime should fulfill when processing published messages.
Args:
subscription (Subscription): The subscription to add
"""
...
async def remove_subscription(self, id: str) -> None:
"""Remove a subscription from the runtime.
Args:
id (str): id of the subscription to remove
Raises:
LookupError: If the subscription does not exist
"""
...
def add_message_serializer(self, serializer: MessageSerializer[Any] | Sequence[MessageSerializer[Any]]) -> None:
"""Add a new message serialization serializer to the runtime.
Note: This will deduplicate serializers based on the type_name and data_content_type properties
Args:
serializer (MessageSerializer[Any] | Sequence[MessageSerializer[Any]]): The serializer/s to add
"""
...
@@ -0,0 +1,25 @@
# Copyright (c) Microsoft. All rights reserved.
__all__ = ["CantHandleException", "MessageDroppedException", "NotAccessibleError", "UndeliverableException"]
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class CantHandleException(Exception):
"""Raised when a handler can't handle the exception."""
@experimental
class UndeliverableException(Exception):
"""Raised when a message can't be delivered."""
@experimental
class MessageDroppedException(Exception):
"""Raised when a message is dropped."""
@experimental
class NotAccessibleError(Exception):
"""Tried to access a value that is not accessible. For example if it is remote cannot be accessed locally."""
@@ -0,0 +1,76 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any, Protocol, final
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.message_context import MessageContext
from semantic_kernel.utils.feature_stage_decorator import experimental
__all__ = [
"DefaultInterventionHandler",
"DropMessage",
"InterventionHandler",
]
@experimental
@final
class DropMessage:
"""Marker type for signalling that a message should be dropped by an intervention handler.
The type itself should be returned from the handler.
"""
...
@experimental
class InterventionHandler(Protocol):
"""An intervention handler is a class that can be used to modify, log or drop messages.
These messages are being processed by the :class:`autogen_core.base.AgentRuntime`.
The handler is called when the message is submitted to the runtime.
Currently the only runtime which supports this is the :class:`autogen_core.base.SingleThreadedAgentRuntime`.
Note: Returning None from any of the intervention handler methods will result in a warning being issued and treated
as "no change". If you intend to drop a message, you should return :class:`DropMessage` explicitly.
"""
async def on_send(
self, message: Any, *, message_context: MessageContext, recipient: AgentId
) -> Any | type[DropMessage]:
"""Called when a message is submitted to the AgentRuntime."""
...
async def on_publish(self, message: Any, *, message_context: MessageContext) -> Any | type[DropMessage]:
"""Called when a message is published to the AgentRuntime."""
...
async def on_response(self, message: Any, *, sender: AgentId, recipient: AgentId | None) -> Any | type[DropMessage]:
"""Called when a response is received by the AgentRuntime from an Agent's message handler returning a value."""
...
@experimental
class DefaultInterventionHandler(InterventionHandler):
"""Simple class that provides a default implementation for all intervention handler methods.
Simply returns the message unchanged. Allows for easy
subclassing to override only the desired methods.
"""
async def on_send(
self, message: Any, *, message_context: MessageContext, recipient: AgentId
) -> Any | type[DropMessage]:
"""Called when a message is submitted to the AgentRuntime."""
return message
async def on_publish(self, message: Any, *, message_context: MessageContext) -> Any | type[DropMessage]:
"""Called when a message is published to the AgentRuntime."""
return message
async def on_response(self, message: Any, *, sender: AgentId, recipient: AgentId | None) -> Any | type[DropMessage]:
"""Called when a response is received by the AgentRuntime from an Agent's message handler returning a value."""
return message
@@ -0,0 +1,130 @@
# Copyright (c) Microsoft. All rights reserved.
import json
from enum import Enum
from typing import Any
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class MessageKind(Enum):
"""Message kind enum."""
DIRECT = 1
PUBLISH = 2
RESPOND = 3
@experimental
class DeliveryStage(Enum):
"""Delivery stage enum."""
SEND = 1
DELIVER = 2
@experimental
class MessageEvent:
"""Base class for message events."""
def __init__(
self,
*,
payload: str,
sender: AgentId | None,
receiver: AgentId | TopicId | None,
kind: MessageKind,
delivery_stage: DeliveryStage,
**kwargs: Any,
) -> None:
"""Initialize a message event."""
self.kwargs = kwargs
self.kwargs["payload"] = payload
self.kwargs["sender"] = None if sender is None else str(sender)
self.kwargs["receiver"] = None if receiver is None else str(receiver)
self.kwargs["kind"] = str(kind)
self.kwargs["delivery_stage"] = str(delivery_stage)
self.kwargs["type"] = "Message"
# This must output the event in a json serializable format
def __str__(self) -> str:
"""Convert the event to a string."""
return json.dumps(self.kwargs)
@experimental
class MessageDroppedEvent:
"""Event for dropped messages."""
def __init__(
self,
*,
payload: str,
sender: AgentId | None,
receiver: AgentId | TopicId | None,
kind: MessageKind,
**kwargs: Any,
) -> None:
"""Initialize a message dropped event."""
self.kwargs = kwargs
self.kwargs["payload"] = payload
self.kwargs["sender"] = None if sender is None else str(sender)
self.kwargs["receiver"] = None if receiver is None else str(receiver)
self.kwargs["kind"] = str(kind)
self.kwargs["type"] = "MessageDropped"
# This must output the event in a json serializable format
def __str__(self) -> str:
"""Convert the event to a string."""
return json.dumps(self.kwargs)
@experimental
class MessageHandlerExceptionEvent:
"""Event for exceptions in message handlers."""
def __init__(
self,
*,
payload: str,
handling_agent: AgentId,
exception: BaseException,
**kwargs: Any,
) -> None:
"""Initialize a message handler exception event."""
self.kwargs = kwargs
self.kwargs["payload"] = payload
self.kwargs["handling_agent"] = str(handling_agent)
self.kwargs["exception"] = str(exception)
self.kwargs["type"] = "MessageHandlerException"
# This must output the event in a json serializable format
def __str__(self) -> str:
"""Convert the event to a string."""
return json.dumps(self.kwargs)
@experimental
class AgentConstructionExceptionEvent:
"""Event for exceptions during agent construction."""
def __init__(
self,
*,
agent_id: AgentId,
exception: BaseException,
**kwargs: Any,
) -> None:
"""Initialize an agent construction exception event."""
self.kwargs = kwargs
self.kwargs["agent_id"] = str(agent_id)
self.kwargs["exception"] = str(exception)
self.kwargs["type"] = "AgentConstructionException"
# This must output the event in a json serializable format
def __str__(self) -> str:
"""Convert the event to a string."""
return json.dumps(self.kwargs)
@@ -0,0 +1,20 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.cancellation_token import CancellationToken
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
@dataclass
class MessageContext:
"""Context for a message sent to an agent."""
sender: AgentId | None
topic_id: TopicId | None
is_rpc: bool
cancellation_token: CancellationToken
message_id: str
@@ -0,0 +1,530 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from collections.abc import Callable, Coroutine, Sequence
from functools import wraps
from typing import Any, DefaultDict, Literal, Protocol, TypeVar, cast, get_type_hints, overload, runtime_checkable
from semantic_kernel.agents.runtime.core.base_agent import BaseAgent
from semantic_kernel.agents.runtime.core.exceptions import CantHandleException
from semantic_kernel.agents.runtime.core.message_context import MessageContext
from semantic_kernel.agents.runtime.core.serialization import MessageSerializer, try_get_known_serializers_for_type
from semantic_kernel.agents.runtime.core.type_helpers import AnyType, get_types
from semantic_kernel.utils.feature_stage_decorator import experimental
logger = logging.getLogger("agent_runtime.core")
AgentT = TypeVar("AgentT")
ReceivesT = TypeVar("ReceivesT")
ProducesT = TypeVar("ProducesT", covariant=True)
# TODO(evmattso): Generic typevar bound binding U to agent type
# Can't do because python doesnt support it
# region MessageHandler Protocol and Methods
# Pyright and mypy disagree on the variance of ReceivesT. Mypy thinks it should be contravariant here.
# Revisit this later to see if we can remove the ignore.
@experimental
@runtime_checkable
class MessageHandler(Protocol[AgentT, ReceivesT, ProducesT]): # type: ignore
"""A protocol for message handlers."""
target_types: Sequence[type]
produces_types: Sequence[type]
is_message_handler: Literal[True]
router: Callable[[ReceivesT, MessageContext], bool]
# agent_instance binds to self in the method
@staticmethod
async def __call__(agent_instance: AgentT, message: ReceivesT, ctx: MessageContext) -> ProducesT:
"""Override the __call__ method to make this a callable class."""
...
# NOTE: this works on concrete types and not inheritance
# TODO(evmattso): Use a protocol for the outer function to check checked arg names
@experimental
@overload
def message_handler(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]],
) -> MessageHandler[AgentT, ReceivesT, ProducesT]: ...
@experimental
@overload
def message_handler(
func: None = None,
*,
match: None = ...,
strict: bool = ...,
) -> Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]]],
MessageHandler[AgentT, ReceivesT, ProducesT],
]: ...
@experimental
@overload
def message_handler(
func: None = None,
*,
match: Callable[[ReceivesT, MessageContext], bool],
strict: bool = ...,
) -> Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]]],
MessageHandler[AgentT, ReceivesT, ProducesT],
]: ...
@experimental
def message_handler(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]] | None = None,
*,
strict: bool = True,
match: Callable[[ReceivesT, MessageContext], bool] | None = None,
) -> (
Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]]],
MessageHandler[AgentT, ReceivesT, ProducesT],
]
| MessageHandler[AgentT, ReceivesT, ProducesT]
):
"""Decorator for generic message handlers.
Add this decorator to methods in a :class:`RoutedAgent` class that are intended to handle both
event and RPC messages.
These methods must have a specific signature that needs to be followed for it to be valid:
- The method must be an `async` method.
- The method must be decorated with the `@message_handler` decorator.
- The method must have exactly 3 arguments:
1. `self`
2. `message`: The message to be handled, this must be type-hinted with the message type that it is
intended to handle.
3. `ctx`: A :class:`agent_runtime.core.MessageContext` object.
- The method must be type hinted with what message types it can return as a response, or it can return `None` if
it does not return anything.
Handlers can handle more than one message type by accepting a Union of the message types. It can also return more
than one message type by returning a Union of the message types.
Args:
func: The function to be decorated.
strict: If `True`, the handler will raise an exception if the message type or return type is not in the target
types. If `False`, it will log a warning instead.
match: A function that takes the message and the context as arguments and returns a boolean. This is used for
secondary routing after the message type. For handlers addressing the same message type, the match function
is applied in alphabetical order of the handlers and the first matching handler will be called while the
rest are skipped. If `None`, the first handler in alphabetical order matching the same message type will
be called.
"""
def decorator(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]],
) -> MessageHandler[AgentT, ReceivesT, ProducesT]:
type_hints = get_type_hints(func)
if "message" not in type_hints:
raise AssertionError("message parameter not found in function signature")
if "return" not in type_hints:
raise AssertionError("return not found in function signature")
# Get the type of the message parameter
target_types = get_types(type_hints["message"])
if target_types is None:
raise AssertionError("Message type not found")
return_types = get_types(type_hints["return"])
if return_types is None:
raise AssertionError("Return type not found")
# Convert target_types to list and stash
@wraps(func)
async def wrapper(self: AgentT, message: ReceivesT, ctx: MessageContext) -> ProducesT:
if type(message) not in target_types:
if strict:
raise CantHandleException(f"Message type {type(message)} not in target types {target_types}")
logger.warning(f"Message type {type(message)} not in target types {target_types}")
return_value = await func(self, message, ctx)
if AnyType not in return_types and type(return_value) not in return_types:
if strict:
raise ValueError(f"Return type {type(return_value)} not in return types {return_types}")
logger.warning(f"Return type {type(return_value)} not in return types {return_types}")
return return_value
wrapper_handler = cast(MessageHandler[AgentT, ReceivesT, ProducesT], wrapper)
wrapper_handler.target_types = list(target_types)
wrapper_handler.produces_types = list(return_types)
wrapper_handler.is_message_handler = True
wrapper_handler.router = match or (lambda _message, _ctx: True)
return wrapper_handler
if func is None and not callable(func):
return decorator
if callable(func):
return decorator(func)
raise ValueError("Invalid arguments")
# endregion
# region Message Handler Decorators
@experimental
@overload
def event(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, None]],
) -> MessageHandler[AgentT, ReceivesT, None]: ...
@experimental
@overload
def event(
func: None = None,
*,
match: None = ...,
strict: bool = ...,
) -> Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, None]]],
MessageHandler[AgentT, ReceivesT, None],
]: ...
@experimental
@overload
def event(
func: None = None,
*,
match: Callable[[ReceivesT, MessageContext], bool],
strict: bool = ...,
) -> Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, None]]],
MessageHandler[AgentT, ReceivesT, None],
]: ...
@experimental
def event(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, None]] | None = None,
*,
strict: bool = True,
match: Callable[[ReceivesT, MessageContext], bool] | None = None,
) -> (
Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, None]]],
MessageHandler[AgentT, ReceivesT, None],
]
| MessageHandler[AgentT, ReceivesT, None]
):
"""Decorator for event message handlers.
Add this decorator to methods in a :class:`RoutedAgent` class that are intended to handle event messages.
These methods must have a specific signature that needs to be followed for it to be valid:
- The method must be an `async` method.
- The method must be decorated with the `@message_handler` decorator.
- The method must have exactly 3 arguments:
1. `self`
2. `message`: The event message to be handled, this must be type-hinted with the message type that it is
intended to handle.
3. `ctx`: A :class:`agent_runtime.core.MessageContext` object.
- The method must return `None`.
Handlers can handle more than one message type by accepting a Union of the message types.
Args:
func: The function to be decorated.
strict: If `True`, the handler will raise an exception if the message type is not in the target types.
If `False`, it will log a warning instead.
match: A function that takes the message and the context as arguments and returns a boolean. This is used for
secondary routing after the message type. For handlers addressing the same message type, the match function
is applied in alphabetical order of the handlers and the first matching handler will be called while the
rest are skipped. If `None`, the first handler in alphabetical order matching the same message type will be
called.
"""
def decorator(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, None]],
) -> MessageHandler[AgentT, ReceivesT, None]:
type_hints = get_type_hints(func)
if "message" not in type_hints:
raise AssertionError("message parameter not found in function signature")
if "return" not in type_hints:
raise AssertionError("return not found in function signature")
# Get the type of the message parameter
target_types = get_types(type_hints["message"])
if target_types is None:
raise AssertionError("Message type not found. Please provide a type hint for the message parameter.")
return_types = get_types(type_hints["return"])
if return_types is None:
raise AssertionError("Return type not found. Please use `None` as the type hint of the return type.")
# Convert target_types to list and stash
@wraps(func)
async def wrapper(self: AgentT, message: ReceivesT, ctx: MessageContext) -> None:
if type(message) not in target_types:
if strict:
raise CantHandleException(f"Message type {type(message)} not in target types {target_types}")
logger.warning(f"Message type {type(message)} not in target types {target_types}")
return_value = await func(self, message, ctx) # type: ignore
if return_value is not None:
if strict:
raise ValueError(f"Return type {type(return_value)} is not None.")
logger.warning(f"Return type {type(return_value)} is not None. It will be ignored.")
return
wrapper_handler = cast(MessageHandler[AgentT, ReceivesT, None], wrapper)
wrapper_handler.target_types = list(target_types)
wrapper_handler.produces_types = list(return_types)
wrapper_handler.is_message_handler = True
# Wrap the match function with a check on the is_rpc flag.
wrapper_handler.router = lambda _message, _ctx: (not _ctx.is_rpc) and (match(_message, _ctx) if match else True)
return wrapper_handler
if func is None and not callable(func):
return decorator
if callable(func):
return decorator(func)
raise ValueError("Invalid arguments")
@experimental
@overload
def rpc(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]],
) -> MessageHandler[AgentT, ReceivesT, ProducesT]: ...
@experimental
@overload
def rpc(
func: None = None,
*,
match: None = ...,
strict: bool = ...,
) -> Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]]],
MessageHandler[AgentT, ReceivesT, ProducesT],
]: ...
@experimental
@overload
def rpc(
func: None = None,
*,
match: Callable[[ReceivesT, MessageContext], bool],
strict: bool = ...,
) -> Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]]],
MessageHandler[AgentT, ReceivesT, ProducesT],
]: ...
@experimental
def rpc(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]] | None = None,
*,
strict: bool = True,
match: Callable[[ReceivesT, MessageContext], bool] | None = None,
) -> (
Callable[
[Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]]],
MessageHandler[AgentT, ReceivesT, ProducesT],
]
| MessageHandler[AgentT, ReceivesT, ProducesT]
):
"""Decorator for RPC message handlers.
Add this decorator to methods in a :class:`RoutedAgent` class that are intended to handle RPC messages.
These methods must have a specific signature that needs to be followed for it to be valid:
- The method must be an `async` method.
- The method must be decorated with the `@message_handler` decorator.
- The method must have exactly 3 arguments:
1. `self`
2. `message`: The message to be handled, this must be type-hinted with the message type that it is intended to
handle.
3. `ctx`: A :class:`agent_runtime.core.MessageContext` object.
- The method must be type hinted with what message types it can return as a response, or it can return `None` if
it does not return anything.
Handlers can handle more than one message type by accepting a Union of the message types. It can also return more
than one message type by returning a Union of the message types.
Args:
func: The function to be decorated.
strict: If `True`, the handler will raise an exception if the message type or return type is not in the target
types. If `False`, it will log a warning instead.
match: A function that takes the message and the context as arguments and returns a boolean. This is used for
secondary routing after the message type. For handlers addressing the same message type, the match function
is applied in alphabetical order of the handlers and the first matching handler will be called while the
rest are skipped. If `None`, the first handler in alphabetical order matching the same message type will be
called.
"""
def decorator(
func: Callable[[AgentT, ReceivesT, MessageContext], Coroutine[Any, Any, ProducesT]],
) -> MessageHandler[AgentT, ReceivesT, ProducesT]:
type_hints = get_type_hints(func)
if "message" not in type_hints:
raise AssertionError("message parameter not found in function signature")
if "return" not in type_hints:
raise AssertionError("return not found in function signature")
# Get the type of the message parameter
target_types = get_types(type_hints["message"])
if target_types is None:
raise AssertionError("Message type not found")
return_types = get_types(type_hints["return"])
if return_types is None:
raise AssertionError("Return type not found")
# Convert target_types to list and stash
@wraps(func)
async def wrapper(self: AgentT, message: ReceivesT, ctx: MessageContext) -> ProducesT:
if type(message) not in target_types:
if strict:
raise CantHandleException(f"Message type {type(message)} not in target types {target_types}")
logger.warning(f"Message type {type(message)} not in target types {target_types}")
return_value = await func(self, message, ctx)
if AnyType not in return_types and type(return_value) not in return_types:
if strict:
raise ValueError(f"Return type {type(return_value)} not in return types {return_types}")
logger.warning(f"Return type {type(return_value)} not in return types {return_types}")
return return_value
wrapper_handler = cast(MessageHandler[AgentT, ReceivesT, ProducesT], wrapper)
wrapper_handler.target_types = list(target_types)
wrapper_handler.produces_types = list(return_types)
wrapper_handler.is_message_handler = True
wrapper_handler.router = lambda _message, _ctx: (_ctx.is_rpc) and (match(_message, _ctx) if match else True)
return wrapper_handler
if func is None and not callable(func):
return decorator
if callable(func):
return decorator(func)
raise ValueError("Invalid arguments")
# endregion
# region RoutedAgent
@experimental
class RoutedAgent(BaseAgent):
"""A base class for agents that route messages to handlers.
Messages are routed based on the type of the message and optional matching functions.
To create a routed agent, subclass this class and add message handlers as methods decorated with
either :func:`event` or :func:`rpc` decorator.
"""
def __init__(self, description: str) -> None:
"""Initialize the routed agent.
Args:
description: The description of the agent.
"""
# Self is already bound to the handlers
self._handlers: DefaultDict[
type[Any],
list[MessageHandler[RoutedAgent, Any, Any]],
] = DefaultDict(list)
handlers = self._discover_handlers()
for message_handler in handlers:
for target_type in message_handler.target_types:
self._handlers[target_type].append(message_handler)
super().__init__(description)
async def on_message_impl(self, message: Any, ctx: MessageContext) -> Any | None:
"""Handle a message by routing it to the appropriate message handler.
Do not override this method in subclasses. Instead, add message handlers as methods decorated with
either the :func:`event` or :func:`rpc` decorator.
"""
key_type: type[Any] = type(message) # type: ignore
handlers = self._handlers.get(key_type) # type: ignore
if handlers is not None:
# Iterate over all handlers for this matching message type.
# Call the first handler whose router returns True and then return the result.
for h in handlers:
if h.router(message, ctx):
return await h(self, message, ctx)
return await self.on_unhandled_message(message, ctx) # type: ignore
async def on_unhandled_message(self, message: Any, ctx: MessageContext) -> None:
"""Called when a message is received that does not have a matching message handler.
The default implementation logs an info message.
Args:
message: The message that was not handled.
ctx: The context of the message.
"""
logger.info(f"Unhandled message: {message}")
@classmethod
def _discover_handlers(cls) -> Sequence[MessageHandler[Any, Any, Any]]:
handlers: list[MessageHandler[Any, Any, Any]] = []
for attr in dir(cls):
if callable(getattr(cls, attr, None)):
# Since we are getting it from the class, self is not bound
handler = getattr(cls, attr)
if hasattr(handler, "is_message_handler"):
handlers.append(cast(MessageHandler[Any, Any, Any], handler))
return handlers
@classmethod
def _handles_types(cls) -> list[tuple[type[Any], list[MessageSerializer[Any]]]]:
# TODO(evmattso): handle deduplication
handlers = cls._discover_handlers()
types: list[tuple[type[Any], list[MessageSerializer[Any]]]] = []
types.extend(cls.internal_extra_handles_types)
for handler in handlers:
for t in handler.target_types:
# TODO(evmattso): support different serializers
serializers = try_get_known_serializers_for_type(t)
if len(serializers) == 0:
raise ValueError(f"No serializers found for type {t}.")
types.append((t, try_get_known_serializers_for_type(t)))
return types
# endregion
@@ -0,0 +1,316 @@
# Copyright (c) Microsoft. All rights reserved.
import json
from collections.abc import Sequence
from dataclasses import asdict, dataclass, fields
from typing import Any, ClassVar, Protocol, TypeVar, cast, get_args, get_origin, runtime_checkable
from google.protobuf import any_pb2
from google.protobuf.message import Message
from pydantic import BaseModel
from semantic_kernel.agents.runtime.core.type_helpers import is_union
from semantic_kernel.utils.feature_stage_decorator import experimental
T = TypeVar("T")
@experimental
class MessageSerializer(Protocol[T]):
"""Serializer for messages."""
@property
def data_content_type(self) -> str:
"""Content type of the data being serialized."""
...
@property
def type_name(self) -> str:
"""Type name of the message being serialized."""
...
def deserialize(self, payload: bytes) -> T:
"""Deserialize the payload into a message."""
...
def serialize(self, message: T) -> bytes:
"""Serialize the message into a payload."""
...
@experimental
@runtime_checkable
class IsDataclass(Protocol):
"""Protocol to check if a class is a dataclass."""
# as already noted in comments, checking for this attribute is currently
# the most reliable way to ascertain that something is a dataclass
__dataclass_fields__: ClassVar[dict[str, Any]]
@experimental
def is_dataclass(cls: type[Any]) -> bool:
"""Check if the class is a dataclass."""
return hasattr(cls, "__dataclass_fields__")
@experimental
def has_nested_dataclass(cls: type[IsDataclass]) -> bool:
"""Check if the dataclass has nested dataclasses."""
# iterate fields and check if any of them are dataclasses
return any(is_dataclass(f.type) for f in cls.__dataclass_fields__.values())
@experimental
def contains_a_union(cls: type[IsDataclass]) -> bool:
"""Check if the dataclass contains a union type."""
return any(is_union(f.type) for f in cls.__dataclass_fields__.values())
@experimental
def has_nested_base_model(cls: type[IsDataclass]) -> bool:
"""Check if the dataclass has nested Pydantic BaseModel."""
for f in fields(cls):
field_type = f.type
# Resolve forward references and other annotations
origin = get_origin(field_type)
args = get_args(field_type)
# If the field type is directly a subclass of BaseModel
if isinstance(field_type, type) and issubclass(field_type, BaseModel):
return True
# If the field type is a generic type like List[BaseModel], Tuple[BaseModel, ...], etc.
if origin is not None and args:
for arg in args:
# Recursively check the argument types
if (isinstance(arg, type) and issubclass(arg, BaseModel)) or (
get_origin(arg) is not None and has_nested_base_model_in_type(arg)
):
return True
# Handle Union types
elif args:
for arg in args:
if (isinstance(arg, type) and issubclass(arg, BaseModel)) or (
get_origin(arg) is not None and has_nested_base_model_in_type(arg)
):
return True
return False
@experimental
def has_nested_base_model_in_type(tp: Any) -> bool:
"""Helper function to check if a type or its arguments is a BaseModel subclass."""
origin = get_origin(tp)
args = get_args(tp)
if isinstance(tp, type) and issubclass(tp, BaseModel):
return True
if origin is not None and args:
for arg in args:
if has_nested_base_model_in_type(arg):
return True
return False
DataclassT = TypeVar("DataclassT", bound=IsDataclass)
JSON_DATA_CONTENT_TYPE = "application/json"
"""JSON data content type"""
# TODO(evmattso): what's the correct content type? There seems to be some disagreement over what it should be
PROTOBUF_DATA_CONTENT_TYPE = "application/x-protobuf"
"""Protobuf data content type"""
@experimental
class DataclassJsonMessageSerializer(MessageSerializer[DataclassT]):
"""Serializer for dataclass messages."""
def __init__(self, cls: type[DataclassT]) -> None:
"""Initialize the serializer with a dataclass type."""
if contains_a_union(cls):
raise ValueError("Dataclass has a union type, which is not supported. To use a union, use a Pydantic model")
if has_nested_dataclass(cls) or has_nested_base_model(cls):
raise ValueError(
"Dataclass has nested dataclasses or base models, which are not supported. To use nested types, "
"use a Pydantic model"
)
self.cls = cls
@property
def data_content_type(self) -> str:
"""Return the data content type."""
return JSON_DATA_CONTENT_TYPE
@property
def type_name(self) -> str:
"""Return the type name."""
return _type_name(self.cls)
def deserialize(self, payload: bytes) -> DataclassT:
"""Deserialize the payload into a dataclass message."""
message_str = payload.decode("utf-8")
return self.cls(**json.loads(message_str))
def serialize(self, message: DataclassT) -> bytes:
"""Serialize the dataclass message into a payload."""
return json.dumps(asdict(message)).encode("utf-8")
PydanticT = TypeVar("PydanticT", bound=BaseModel)
@experimental
class PydanticJsonMessageSerializer(MessageSerializer[PydanticT]):
"""Serializer for Pydantic messages."""
def __init__(self, cls: type[PydanticT]) -> None:
"""Initialize the serializer with a Pydantic model type."""
self.cls = cls
@property
def data_content_type(self) -> str:
"""Return the data content type."""
return JSON_DATA_CONTENT_TYPE
@property
def type_name(self) -> str:
"""Return the type name."""
return _type_name(self.cls)
def deserialize(self, payload: bytes) -> PydanticT:
"""Deserialize the payload into a Pydantic model message."""
message_str = payload.decode("utf-8")
return self.cls.model_validate_json(message_str)
def serialize(self, message: PydanticT) -> bytes:
"""Serialize the Pydantic model message into a payload."""
return message.model_dump_json().encode("utf-8")
ProtobufT = TypeVar("ProtobufT", bound=Message)
# This class serializes to and from a google.protobuf.Any message that has been serialized to a string
@experimental
class ProtobufMessageSerializer(MessageSerializer[ProtobufT]):
"""Serializer for Protobuf messages."""
def __init__(self, cls: type[ProtobufT]) -> None:
"""Initialize the serializer with a Protobuf message type."""
self.cls = cls
@property
def data_content_type(self) -> str:
"""Return the data content type."""
return PROTOBUF_DATA_CONTENT_TYPE
@property
def type_name(self) -> str:
"""Return the type name."""
return _type_name(self.cls)
def deserialize(self, payload: bytes) -> ProtobufT:
"""Deserialize the payload into a Protobuf message."""
# Parse payload into a proto any
any_proto = any_pb2.Any()
any_proto.ParseFromString(payload)
destination_message = self.cls()
if not any_proto.Unpack(destination_message): # type: ignore
raise ValueError(f"Failed to unpack payload into {self.cls}")
return destination_message
def serialize(self, message: ProtobufT) -> bytes:
"""Serialize the Protobuf message into a payload."""
any_proto = any_pb2.Any()
any_proto.Pack(message) # type: ignore
return any_proto.SerializeToString()
@experimental
@dataclass
class UnknownPayload:
"""Class to represent an unknown payload."""
type_name: str
data_content_type: str
payload: bytes
def _type_name(cls: type[Any] | Any) -> str:
# If cls is a protobuf, then we need to determine the descriptor
if isinstance(cls, type):
if issubclass(cls, Message):
return cast(str, cls.DESCRIPTOR.full_name) # type: ignore
elif isinstance(cls, Message):
return cast(str, cls.DESCRIPTOR.full_name)
if isinstance(cls, type):
return cls.__name__
return cast(str, cls.__class__.__name__)
V = TypeVar("V")
@experimental
def try_get_known_serializers_for_type(cls: type[Any]) -> list[MessageSerializer[Any]]:
"""Try to get known serializers for a type."""
serializers: list[MessageSerializer[Any]] = []
if issubclass(cls, BaseModel):
serializers.append(PydanticJsonMessageSerializer(cls))
elif is_dataclass(cls):
serializers.append(DataclassJsonMessageSerializer(cls))
elif issubclass(cls, Message):
serializers.append(ProtobufMessageSerializer(cls))
return serializers
@experimental
class SerializationRegistry:
"""Serialization registry for messages."""
def __init__(self) -> None:
"""Initialize the serialization registry."""
# type_name, data_content_type -> serializer
self._serializers: dict[tuple[str, str], MessageSerializer[Any]] = {}
def add_serializer(self, serializer: MessageSerializer[Any] | Sequence[MessageSerializer[Any]]) -> None:
"""Add a new serializer to the registry."""
if isinstance(serializer, Sequence):
for c in serializer:
self.add_serializer(c)
return
self._serializers[serializer.type_name, serializer.data_content_type] = serializer
def deserialize(self, payload: bytes, *, type_name: str, data_content_type: str) -> Any:
"""Deserialize a payload into a message."""
serializer = self._serializers.get((type_name, data_content_type))
if serializer is None:
return UnknownPayload(type_name, data_content_type, payload)
return serializer.deserialize(payload)
def serialize(self, message: Any, *, type_name: str, data_content_type: str) -> bytes:
"""Serialize a message into a payload."""
serializer = self._serializers.get((type_name, data_content_type))
if serializer is None:
raise ValueError(f"Unknown type {type_name} with content type {data_content_type}")
return serializer.serialize(message)
def is_registered(self, type_name: str, data_content_type: str) -> bool:
"""Check if a type is registered in the registry."""
return (type_name, data_content_type) in self._serializers
def type_name(self, message: Any) -> str:
"""Get the type name of a message."""
return _type_name(message)
@@ -0,0 +1,68 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Awaitable, Callable
from typing import Protocol, runtime_checkable
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
@runtime_checkable
class Subscription(Protocol):
"""Subscriptions define the topics that an agent is interested in."""
@property
def id(self) -> str:
"""Get the ID of the subscription.
Implementations should return a unique ID for the subscription. Usually this is a UUID.
Returns:
str: ID of the subscription.
"""
...
def __eq__(self, other: object) -> bool:
"""Check if two subscriptions are equal.
Args:
other (object): Other subscription to compare against.
Returns:
bool: True if the subscriptions are equal, False otherwise.
"""
if not isinstance(other, Subscription):
return False
return self.id == other.id
def is_match(self, topic_id: TopicId) -> bool:
"""Check if a given topic_id matches the subscription.
Args:
topic_id (TopicId): TopicId to check.
Returns:
bool: True if the topic_id matches the subscription, False otherwise.
"""
...
def map_to_agent(self, topic_id: TopicId) -> AgentId:
"""Map a topic_id to an agent. Should only be called if `is_match` returns True for the given topic_id.
Args:
topic_id (TopicId): TopicId to map.
Returns:
AgentId: ID of the agent that should handle the topic_id.
Raises:
CantHandleException: If the subscription cannot handle the topic_id.
"""
...
# Helper alias to represent the lambdas used to define subscriptions
UnboundSubscription = Callable[[], list[Subscription] | Awaitable[list[Subscription]]]
@@ -0,0 +1,19 @@
# Copyright (c) Microsoft. All rights reserved.
from .propagation import (
EnvelopeMetadata,
TelemetryMetadataContainer,
get_telemetry_envelope_metadata,
get_telemetry_grpc_metadata,
)
from .tracing import TraceHelper
from .tracing_config import MessageRuntimeTracingConfig
__all__ = [
"EnvelopeMetadata",
"MessageRuntimeTracingConfig",
"TelemetryMetadataContainer",
"TraceHelper",
"get_telemetry_envelope_metadata",
"get_telemetry_grpc_metadata",
]
@@ -0,0 +1,3 @@
# Copyright (c) Microsoft. All rights reserved.
NAMESPACE = "agent_runtime"
@@ -0,0 +1,132 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Optional
from opentelemetry.context import Context
from opentelemetry.propagate import extract
from opentelemetry.trace import Link, get_current_span
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
@dataclass(kw_only=True)
class EnvelopeMetadata:
"""Metadata for an envelope."""
traceparent: str | None = None
tracestate: str | None = None
links: Sequence[Link] | None = None
def _get_carrier_for_envelope_metadata(envelope_metadata: EnvelopeMetadata) -> dict[str, str]:
carrier: dict[str, str] = {}
if envelope_metadata.traceparent is not None:
carrier["traceparent"] = envelope_metadata.traceparent
if envelope_metadata.tracestate is not None:
carrier["tracestate"] = envelope_metadata.tracestate
return carrier
@experimental
def get_telemetry_envelope_metadata() -> EnvelopeMetadata:
"""Retrieves the telemetry envelope metadata.
Returns:
EnvelopeMetadata: The envelope metadata containing the traceparent and tracestate.
"""
carrier: dict[str, str] = {}
TraceContextTextMapPropagator().inject(carrier)
return EnvelopeMetadata(
traceparent=carrier.get("traceparent"),
tracestate=carrier.get("tracestate"),
)
def _get_carrier_for_remote_call_metadata(remote_call_metadata: Mapping[str, str]) -> dict[str, str]:
carrier: dict[str, str] = {}
traceparent = remote_call_metadata.get("traceparent")
tracestate = remote_call_metadata.get("tracestate")
if traceparent:
carrier["traceparent"] = traceparent
if tracestate:
carrier["tracestate"] = tracestate
return carrier
@experimental
def get_telemetry_grpc_metadata(existingMetadata: Mapping[str, str] | None = None) -> dict[str, str]:
"""Retrieves the telemetry gRPC metadata.
Args:
existingMetadata (Optional[Mapping[str, str]]): The existing metadata to include in the gRPC metadata.
Returns:
Mapping[str, str]: The gRPC metadata containing the traceparent and tracestate.
"""
carrier: dict[str, str] = {}
TraceContextTextMapPropagator().inject(carrier)
traceparent = carrier.get("traceparent")
tracestate = carrier.get("tracestate")
metadata: dict[str, str] = {}
if existingMetadata is not None:
for key, value in existingMetadata.items():
metadata[key] = value
if traceparent is not None:
metadata["traceparent"] = traceparent
if tracestate is not None:
metadata["tracestate"] = tracestate
return metadata
TelemetryMetadataContainer = Optional[EnvelopeMetadata] | Mapping[str, str]
@experimental
def get_telemetry_context(metadata: TelemetryMetadataContainer) -> Context:
"""Retrieves the telemetry context from the given metadata.
Args:
metadata (Optional[EnvelopeMetadata]): The metadata containing the telemetry context.
Returns:
Context: The telemetry context extracted from the metadata, or an empty context if the metadata is None.
"""
if metadata is None:
return Context()
if isinstance(metadata, EnvelopeMetadata):
return extract(_get_carrier_for_envelope_metadata(metadata))
if hasattr(metadata, "__getitem__"):
return extract(_get_carrier_for_remote_call_metadata(metadata))
raise ValueError(f"Unknown metadata type: {type(metadata)}")
@experimental
def get_telemetry_links(
metadata: TelemetryMetadataContainer,
) -> Sequence[Link] | None:
"""Retrieves the telemetry links from the given metadata.
Args:
metadata (Optional[EnvelopeMetadata]): The metadata containing the telemetry links.
Returns:
Optional[Sequence[Link]]: The telemetry links extracted from the metadata, or None if there are no links.
"""
if metadata is None:
return None
if isinstance(metadata, EnvelopeMetadata):
context = extract(_get_carrier_for_envelope_metadata(metadata))
elif hasattr(metadata, "__getitem__"):
context = extract(_get_carrier_for_remote_call_metadata(metadata))
else:
return None
# Retrieve the extracted SpanContext from the context.
linked_span = get_current_span(context)
# Use the linked span to get the SpanContext.
span_context = linked_span.get_span_context()
# Create a Link object using the SpanContext.
return [Link(span_context)]
@@ -0,0 +1,102 @@
# Copyright (c) Microsoft. All rights reserved.
import contextlib
from collections.abc import Iterator
from typing import Generic
from opentelemetry.trace import NoOpTracerProvider, Span, SpanKind, TracerProvider, get_tracer_provider
from opentelemetry.util import types
from semantic_kernel.agents.runtime.core.telemetry.propagation import TelemetryMetadataContainer, get_telemetry_links
from semantic_kernel.agents.runtime.core.telemetry.tracing_config import (
Destination,
ExtraAttributes,
Operation,
TracingConfig,
)
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class TraceHelper(Generic[Operation, Destination, ExtraAttributes]):
"""TraceHelper is a utility class to assist with tracing operations using OpenTelemetry.
This class provides a context manager `trace_block` to create and manage spans for tracing operations,
following semantic conventions and supporting nested spans through metadata contexts.
"""
def __init__(
self,
tracer_provider: TracerProvider | None,
instrumentation_builder_config: TracingConfig[Operation, Destination, ExtraAttributes],
) -> None:
"""Initialize the TraceHelper with a tracer provider and instrumentation builder config."""
# Evaluate in order: first try tracer_provider param, then get_tracer_provider(), finally fallback to NoOp
# This allows for nested tracing with a default tracer provided by the user
self.tracer_provider = tracer_provider or get_tracer_provider() or NoOpTracerProvider()
self.tracer = self.tracer_provider.get_tracer(f"agent_runtime {instrumentation_builder_config.name}")
self.instrumentation_builder_config = instrumentation_builder_config
@contextlib.contextmanager
def trace_block(
self,
operation: Operation,
destination: Destination,
parent: TelemetryMetadataContainer | None,
*,
extraAttributes: ExtraAttributes | None = None,
kind: SpanKind | None = None,
attributes: types.Attributes | None = None,
start_time: int | None = None,
record_exception: bool = True,
set_status_on_exception: bool = True,
end_on_exit: bool = True,
) -> Iterator[Span]:
"""Thin wrapper on top of start_as_current_span.
1. It helps us follow semantic conventions
2. It helps us get contexts from metadata so we can get nested spans
Args:
operation (MessagingOperation): The messaging operation being performed.
destination (MessagingDestination): The messaging destination being used.
parent (Optional[TelemetryMetadataContainer]): The parent telemetry metadata context
kind (SpanKind, optional): The kind of span. If not provided, it maps to PRODUCER or CONSUMER depending
on the operation.
extraAttributes (ExtraAttributes, optional): Additional defined attributes for the span. Defaults to None.
attributes (Optional[types.Attributes], optional): Additional non-defined attributes for the span.
Defaults to None.
start_time (Optional[int], optional): The start time of the span. Defaults to None.
record_exception (bool, optional): Whether to record exceptions. Defaults to True.
set_status_on_exception (bool, optional): Whether to set the status on exception. Defaults to True.
end_on_exit (bool, optional): Whether to end the span on exit. Defaults to True.
Yields:
Iterator[Span]: The span object.
"""
span_name = self.instrumentation_builder_config.get_span_name(operation, destination)
span_kind = kind or self.instrumentation_builder_config.get_span_kind(operation)
context = None # TODO(evmattso): we may need to remove other code for using custom context.
links = get_telemetry_links(parent) if parent else None
attributes_with_defaults: dict[str, types.AttributeValue] = {}
for key, value in (attributes or {}).items():
attributes_with_defaults[key] = value
instrumentation_attributes = self.instrumentation_builder_config.build_attributes(
operation, destination, extraAttributes
)
for key, value in instrumentation_attributes.items():
attributes_with_defaults[key] = value
with self.tracer.start_as_current_span(
span_name,
context,
span_kind,
attributes_with_defaults,
links,
start_time,
record_exception,
set_status_on_exception,
end_on_exit,
) as span:
yield span
@@ -0,0 +1,201 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from abc import ABC, abstractmethod
from typing import Generic, Literal, TypedDict, TypeVar, Union
from opentelemetry.trace import SpanKind
from opentelemetry.util import types
from typing_extensions import NotRequired
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.telemetry.constants import NAMESPACE
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.utils.feature_stage_decorator import experimental
logger = logging.getLogger("agent_runtime")
event_logger = logging.getLogger("agent_runtime.events")
Operation = TypeVar("Operation", bound=str)
Destination = TypeVar("Destination")
ExtraAttributes = TypeVar("ExtraAttributes")
@experimental
class TracingConfig(ABC, Generic[Operation, Destination, ExtraAttributes]):
"""A protocol that defines the configuration for instrumentation.
This protocol specifies the required properties and methods that any
instrumentation configuration class must implement. It includes a
property to get the name of the module being instrumented and a method
to build attributes for the instrumentation configuration.
"""
@property
@abstractmethod
def name(self) -> str:
"""Gets the name of the module being instrumented."""
...
@abstractmethod
def build_attributes(
self,
operation: Operation,
destination: Destination,
extraAttributes: ExtraAttributes | None,
) -> dict[str, types.AttributeValue]:
"""Builds the attributes for the instrumentation configuration.
Returns:
Dict[str, str]: The attributes for the instrumentation configuration.
"""
...
@abstractmethod
def get_span_name(
self,
operation: Operation,
destination: Destination,
) -> str:
"""Returns the span name based on the given operation and destination.
Parameters:
operation (MessagingOperation): The messaging operation.
destination (Optional[MessagingDestination]): The messaging destination.
Returns:
str: The span name.
"""
...
@abstractmethod
def get_span_kind(
self,
operation: Operation,
) -> SpanKind:
"""Determines the span kind based on the given messaging operation.
Parameters:
operation (MessagingOperation): The messaging operation.
Returns:
SpanKind: The span kind based on the messaging operation.
"""
@experimental
class ExtraMessageRuntimeAttributes(TypedDict):
"""A dictionary of extra attributes for message runtime instrumentation."""
message_size: NotRequired[int]
message_type: NotRequired[str]
MessagingDestination = Union[AgentId, TopicId, str, None]
MessagingOperation = Literal["create", "send", "publish", "receive", "intercept", "process", "ack"]
@experimental
class MessageRuntimeTracingConfig(
TracingConfig[MessagingOperation, MessagingDestination, ExtraMessageRuntimeAttributes]
):
"""A class that defines the configuration for message runtime instrumentation.
This class implements the TracingConfig protocol and provides
the name of the module being instrumented and the attributes for the
instrumentation configuration.
"""
def __init__(self, runtime_name: str) -> None:
"""Initialize the MessageRuntimeTracingConfig with the runtime name."""
self._runtime_name = runtime_name
@property
def name(self) -> str:
"""Get the name of the module being instrumented."""
return self._runtime_name
def build_attributes(
self,
operation: MessagingOperation,
destination: MessagingDestination,
extraAttributes: ExtraMessageRuntimeAttributes | None,
) -> dict[str, types.AttributeValue]:
"""Build the attributes for the instrumentation configuration."""
attrs: dict[str, types.AttributeValue] = {
"messaging.operation": self._get_operation_type(operation),
"messaging.destination": self._get_destination_str(destination),
}
if extraAttributes:
# TODO(evmattso): Make this more pythonic?
if "message_size" in extraAttributes:
attrs["messaging.message.envelope.size"] = extraAttributes["message_size"]
if "message_type" in extraAttributes:
attrs["messaging.message.type"] = extraAttributes["message_type"]
return attrs
def get_span_name(
self,
operation: MessagingOperation,
destination: MessagingDestination,
) -> str:
"""Returns the span name based on the given operation and destination.
Semantic Conventions - https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/#span-name
Parameters:
operation (MessagingOperation): The messaging operation.
destination (Optional[MessagingDestination]): The messaging destination.
Returns:
str: The span name.
"""
span_parts: list[str] = [operation]
destination_str = self._get_destination_str(destination)
if destination_str:
span_parts.append(destination_str)
span_name = " ".join(span_parts)
return f"{NAMESPACE} {span_name}"
def get_span_kind(
self,
operation: MessagingOperation,
) -> SpanKind:
"""Determines the span kind based on the given messaging operation.
Semantic Conventions - https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/#span-kind
Parameters:
operation (MessagingOperation): The messaging operation.
Returns:
SpanKind: The span kind based on the messaging operation.
"""
if operation in ["create", "send", "publish"]:
return SpanKind.PRODUCER
if operation in ["receive", "intercept", "process", "ack"]:
return SpanKind.CONSUMER
return SpanKind.CLIENT
# TODO(evmattso): Use stringified convention
def _get_destination_str(self, destination: MessagingDestination) -> str:
if isinstance(destination, AgentId):
return f"{destination.type}.({destination.key})-A"
if isinstance(destination, TopicId):
return f"{destination.type}.({destination.source})-T"
if isinstance(destination, str):
return destination
if destination is None:
return ""
raise ValueError(f"Unknown destination type: {type(destination)}")
def _get_operation_type(self, operation: MessagingOperation) -> str:
if operation in ["send", "publish"]:
return "publish"
if operation in ["create"]:
return "create"
if operation in ["receive", "intercept", "ack"]:
return "receive"
if operation in ["process"]:
return "process"
return "Unknown"
@@ -0,0 +1,58 @@
# Copyright (c) Microsoft. All rights reserved.
import re
from dataclasses import dataclass
from typing_extensions import Self
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
def is_valid_topic_type(value: str) -> bool:
"""Check if the given value is a valid topic type."""
return bool(re.match(r"^[\w\-\.\:\=]+\Z", value))
@experimental
@dataclass(eq=True, frozen=True)
class TopicId:
"""TopicId defines the scope of a broadcast message.
In essence, agent runtime implements a publish-subscribe model through its broadcast API: when publishing a message,
the topic must be specified.
See here for more information: :ref:`topic_and_subscription_topic`
"""
type: str
"""Type of the event that this topic_id contains. Adhere's to the cloud event spec.
Must match the pattern: ^[\\w\\-\\.\\:\\=]+\\Z
Learn more here: https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md#type
"""
source: str
"""Identifies the context in which an event happened. Adhere's to the cloud event spec.
Learn more here: https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md#source-1
"""
def __post_init__(self) -> None:
"""Validate the topic type and source."""
if is_valid_topic_type(self.type) is False:
raise ValueError(f"Invalid topic type: {self.type}. Must match the pattern: ^[\\w\\-\\.\\:\\=]+\\Z")
def __str__(self) -> str:
"""Convert the TopicId to a string."""
return f"{self.type}/{self.source}"
@classmethod
def from_str(cls, topic_id: str) -> Self:
"""Convert a string of the format ``type/source`` into a TopicId."""
items = topic_id.split("/", maxsplit=1)
if len(items) != 2:
raise ValueError(f"Invalid topic id: {topic_id}")
type, source = items[0], items[1]
return cls(type, source)
@@ -0,0 +1,45 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Sequence
from types import NoneType, UnionType
from typing import Any, Optional, Union, get_args, get_origin
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
def is_union(t: object) -> bool:
"""Check if the type is a Union or UnionType."""
origin = get_origin(t)
return origin is Union or origin is UnionType
@experimental
def is_optional(t: object) -> bool:
"""Check if the type is an Optional."""
origin = get_origin(t)
return origin is Optional
# Special type to avoid the 3.10 vs 3.11+ difference of typing._SpecialForm vs typing.Any
@experimental
class AnyType:
"""Special type to represent Any."""
pass
@experimental
def get_types(t: object) -> Sequence[type[Any]] | None:
"""Get the types from a Union or Optional type."""
if is_union(t):
return get_args(t)
if is_optional(t):
return tuple([*list(get_args(t)), NoneType])
if t is Any:
return (AnyType,)
if isinstance(t, type):
return (t,)
if isinstance(t, NoneType):
return (NoneType,)
return None
@@ -0,0 +1,13 @@
# Copyright (c) Microsoft. All rights reserved.
import re
from semantic_kernel.utils.feature_stage_decorator import experimental
_AGENT_TYPE_REGEX = re.compile(r"^[\w\-\.]+\Z")
@experimental
def is_valid_agent_type(value: str) -> bool:
"""Check if the agent type is valid."""
return bool(_AGENT_TYPE_REGEX.match(value))
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,65 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, ClassVar
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.agents.runtime.core.core_runtime import CoreRuntime
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class AgentInstantiationContext:
"""A static class that provides context for agent instantiation.
This static class can be used to access the current runtime and agent ID
during agent instantiation -- inside the factory function or the agent's
class constructor.
"""
def __init__(self) -> None:
"""Instantiate the AgentInstantiationContext class."""
raise RuntimeError(
"AgentInstantiationContext cannot be instantiated. It is a static class that provides context management "
"for agent instantiation."
)
_AGENT_INSTANTIATION_CONTEXT_VAR: ClassVar[ContextVar[tuple[CoreRuntime, AgentId]]] = ContextVar(
"_AGENT_INSTANTIATION_CONTEXT_VAR"
)
@classmethod
@contextmanager
def populate_context(cls, ctx: tuple[CoreRuntime, AgentId]) -> Generator[None, Any, None]:
"""Populate the context with the current runtime and agent ID."""
token = AgentInstantiationContext._AGENT_INSTANTIATION_CONTEXT_VAR.set(ctx)
try:
yield
finally:
AgentInstantiationContext._AGENT_INSTANTIATION_CONTEXT_VAR.reset(token)
@classmethod
def current_runtime(cls) -> CoreRuntime:
"""Get the current runtime."""
try:
return cls._AGENT_INSTANTIATION_CONTEXT_VAR.get()[0]
except LookupError as e:
raise RuntimeError(
"AgentInstantiationContext.runtime() must be called within an instantiation context such as when the "
"AgentRuntime is instantiating an agent. Mostly likely this was caused by directly instantiating an "
"agent instead of using the AgentRuntime to do so."
) from e
@classmethod
def current_agent_id(cls) -> AgentId:
"""Get the current agent ID."""
try:
return cls._AGENT_INSTANTIATION_CONTEXT_VAR.get()[1]
except LookupError as e:
raise RuntimeError(
"AgentInstantiationContext.agent_id() must be called within an instantiation context such as when the "
"AgentRuntime is instantiating an agent. Mostly likely this was caused by directly instantiating an "
"agent instead of using the AgentRuntime to do so."
) from e
@@ -0,0 +1,65 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Callable
from typing import TypeVar, overload
from semantic_kernel.agents.runtime.core.agent_type import AgentType
from semantic_kernel.agents.runtime.core.base_agent import BaseAgent, subscription_factory
from semantic_kernel.agents.runtime.core.exceptions import CantHandleException
from semantic_kernel.agents.runtime.in_process.subscription_context import SubscriptionInstantiationContext
from semantic_kernel.agents.runtime.in_process.type_subscription import TypeSubscription
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class DefaultSubscription(TypeSubscription):
"""The default subscription is designed to be a default for applications that only need global scope for agents.
This topic by default uses the "default" topic type and attempts to detect the agent type to use based on the
instantiation context.
Args:
topic_type (str, optional): The topic type to subscribe to. Defaults to "default".
agent_type (str, optional): The agent type to use for the subscription. Defaults to None, in which case it
will attempt to detect the agent type based on the instantiation context.
"""
def __init__(self, topic_type: str = "default", agent_type: str | AgentType | None = None):
"""Initialize the DefaultSubscription."""
if agent_type is None:
try:
agent_type = SubscriptionInstantiationContext.agent_type().type
except RuntimeError as e:
raise CantHandleException(
"If agent_type is not specified DefaultSubscription must be created within the subscription "
"callback in AgentRuntime.register"
) from e
super().__init__(topic_type, agent_type)
BaseAgentType = TypeVar("BaseAgentType", bound="BaseAgent")
@overload
def default_subscription() -> Callable[[type[BaseAgentType]], type[BaseAgentType]]: ...
@overload
def default_subscription(cls: type[BaseAgentType]) -> type[BaseAgentType]: ...
@experimental
def default_subscription(
cls: type[BaseAgentType] | None = None,
) -> Callable[[type[BaseAgentType]], type[BaseAgentType]] | type[BaseAgentType]:
"""Create a default subscription."""
if cls is None:
return subscription_factory(lambda: [DefaultSubscription()])
return subscription_factory(lambda: [DefaultSubscription()])(cls)
@experimental
def type_subscription(topic_type: str) -> Callable[[type[BaseAgentType]], type[BaseAgentType]]:
"""Create a type subscription for the given topic type."""
return subscription_factory(lambda: [DefaultSubscription(topic_type=topic_type)])
@@ -0,0 +1,31 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.agents.runtime.in_process.message_handler_context import MessageHandlerContext
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class DefaultTopicId(TopicId):
"""DefaultTopicId provides a sensible default for the topic_id and source fields of a TopicId.
If created in the context of a message handler, the source will be set to the agent_id of the message handler,
otherwise it will be set to "default".
Args:
type (str, optional): Topic type to publish message to. Defaults to "default".
source (str | None, optional): Topic source to publish message to. If None, the source will be set to the
agent_id of the message handler if in the context of a message handler, otherwise it will be set to
"default". Defaults to None.
"""
def __init__(self, type: str = "default", source: str | None = None) -> None:
"""Initialize the DefaultTopicId."""
if source is None:
try:
source = MessageHandlerContext.agent_id().key
# If we aren't in the context of a message handler, we use the default source
except RuntimeError:
source = "default"
super().__init__(type, source)
@@ -0,0 +1,854 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import inspect
import logging
import sys
import uuid
import warnings
from asyncio import CancelledError, Future, Queue, Task
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Any, ParamSpec, TypeVar, cast
from semantic_kernel.utils.feature_stage_decorator import experimental
if sys.version_info >= (3, 13):
from asyncio import Queue, QueueShutDown
else:
from .queue import Queue, QueueShutDown # type: ignore
from opentelemetry.trace import TracerProvider
from semantic_kernel.agents.runtime.core.agent import Agent
from semantic_kernel.agents.runtime.core.agent_id import AgentId, CoreAgentId
from semantic_kernel.agents.runtime.core.agent_metadata import AgentMetadata
from semantic_kernel.agents.runtime.core.agent_type import AgentType, CoreAgentType
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.exceptions import MessageDroppedException
from semantic_kernel.agents.runtime.core.intervention import DropMessage, InterventionHandler
from semantic_kernel.agents.runtime.core.logging import (
AgentConstructionExceptionEvent,
DeliveryStage,
MessageDroppedEvent,
MessageEvent,
MessageHandlerExceptionEvent,
MessageKind,
)
from semantic_kernel.agents.runtime.core.message_context import MessageContext
from semantic_kernel.agents.runtime.core.serialization import (
JSON_DATA_CONTENT_TYPE,
MessageSerializer,
SerializationRegistry,
)
from semantic_kernel.agents.runtime.core.subscription import Subscription
from semantic_kernel.agents.runtime.core.telemetry import (
EnvelopeMetadata,
MessageRuntimeTracingConfig,
TraceHelper,
get_telemetry_envelope_metadata,
)
from semantic_kernel.agents.runtime.core.topic import TopicId
from .agent_instantiation_context import AgentInstantiationContext
from .message_handler_context import MessageHandlerContext
from .runtime_impl_helpers import SubscriptionManager, get_impl
logger = logging.getLogger("in_process_runtime")
event_logger = logging.getLogger("in_process_runtime.events")
# We use a type parameter in some functions which shadows the built-in `type` function.
# This is a workaround to avoid shadowing the built-in `type` function.
type_func_alias = type
@experimental
@dataclass(kw_only=True)
class PublishMessageEnvelope:
"""A message envelope for publishing messages to all agents that can handle the message of the type T."""
message: Any
cancellation_token: CancellationToken
sender: AgentId | None
topic_id: TopicId
metadata: EnvelopeMetadata | None = None
message_id: str
@experimental
@dataclass(kw_only=True)
class SendMessageEnvelope:
"""A message envelope for sending a message to a specific agent that can handle the message of the type T."""
message: Any
sender: AgentId | None
recipient: AgentId
future: Future[Any]
cancellation_token: CancellationToken
metadata: EnvelopeMetadata | None = None
message_id: str
@experimental
@dataclass(kw_only=True)
class ResponseMessageEnvelope:
"""A message envelope for sending a response to a message."""
message: Any
future: Future[Any]
sender: AgentId
recipient: AgentId | None
metadata: EnvelopeMetadata | None = None
P = ParamSpec("P")
T = TypeVar("T", bound=Agent)
@experimental
class RunContext:
"""A context for the runtime to run in a background task."""
def __init__(self, runtime: "InProcessRuntime") -> None:
"""Initialize the run context."""
self._runtime = runtime
self._run_task = asyncio.create_task(self._run())
self._stopped = asyncio.Event()
async def _run(self) -> None:
while True:
if self._stopped.is_set():
return
await self._runtime._process_next() # type: ignore
async def stop(self) -> None:
"""Stop the runtime message processing loop immediately."""
self._stopped.set()
self._runtime._message_queue.shutdown(immediate=True) # type: ignore
await self._run_task
async def stop_when_idle(self) -> None:
"""Stop the runtime message processing loop when there are no messages in the queue."""
await self._runtime._message_queue.join() # type: ignore
self._stopped.set()
self._runtime._message_queue.shutdown(immediate=True) # type: ignore
await self._run_task
async def stop_when(self, condition: Callable[[], bool], check_period: float = 1.0) -> None:
"""Stop the runtime message processing loop when the condition is met."""
async def check_condition() -> None:
while not condition():
await asyncio.sleep(check_period)
await self.stop()
await asyncio.create_task(check_condition())
def _warn_if_none(value: Any, handler_name: str) -> None:
"""Utility function to check if the intervention handler returned None and issue a warning.
Args:
value: The return value to check
handler_name: Name of the intervention handler method for the warning message
"""
if value is None:
warnings.warn(
f"Intervention handler {handler_name} returned None. This might be unintentional. "
"Consider returning the original message or DropMessage explicitly.",
RuntimeWarning,
stacklevel=2,
)
@experimental
class InProcessRuntime(CoreRuntime):
"""A in-process runtime that processes all messages using a single asyncio queue.
Messages are delivered in the order they are received, and the runtime processes
each message in a separate asyncio task concurrently.
"""
def __init__(
self,
*,
intervention_handlers: list[InterventionHandler] | None = None,
tracer_provider: TracerProvider | None = None,
ignore_unhandled_exceptions: bool = True,
) -> None:
"""Initialize the runtime."""
self._tracer_helper = TraceHelper(tracer_provider, MessageRuntimeTracingConfig("InProcessRuntime"))
self._message_queue: Queue[PublishMessageEnvelope | SendMessageEnvelope | ResponseMessageEnvelope] = Queue()
# (namespace, type) -> List[AgentId]
self._agent_factories: dict[
str, Callable[[], Agent | Awaitable[Agent]] | Callable[[CoreRuntime, AgentId], Agent | Awaitable[Agent]]
] = {}
self._instantiated_agents: dict[AgentId, Agent] = {}
self._intervention_handlers = intervention_handlers
self._background_tasks: set[Task[Any]] = set()
self._subscription_manager = SubscriptionManager()
self._run_context: RunContext | None = None
self._serialization_registry = SerializationRegistry()
self._ignore_unhandled_handler_exceptions = ignore_unhandled_exceptions
self._background_exception: BaseException | None = None
@property
def unprocessed_messages_count(
self,
) -> int:
"""Get the number of unprocessed messages in the queue."""
return self._message_queue.qsize()
@property
def _known_agent_names(self) -> set[str]:
return set(self._agent_factories.keys())
# Returns the response of the message
async def send_message(
self,
message: Any,
recipient: AgentId,
*,
sender: AgentId | None = None,
cancellation_token: CancellationToken | None = None,
message_id: str | None = None,
) -> Any:
"""Send a message to an agent and get a response."""
if cancellation_token is None:
cancellation_token = CancellationToken()
if message_id is None:
message_id = str(uuid.uuid4())
event_logger.info(
MessageEvent(
payload=self._try_serialize(message),
sender=sender,
receiver=recipient,
kind=MessageKind.DIRECT,
delivery_stage=DeliveryStage.SEND,
)
)
with self._tracer_helper.trace_block(
"create",
recipient,
parent=None,
extraAttributes={"message_type": type(message).__name__},
):
future = asyncio.get_event_loop().create_future()
if recipient.type not in self._known_agent_names:
future.set_exception(Exception("Recipient not found"))
content = message.__dict__ if hasattr(message, "__dict__") else message
logger.info(f"Sending message of type {type(message).__name__} to {recipient.type}: {content}")
await self._message_queue.put(
SendMessageEnvelope(
message=message,
recipient=recipient,
future=future,
cancellation_token=cancellation_token,
sender=sender,
metadata=get_telemetry_envelope_metadata(),
message_id=message_id,
)
)
cancellation_token.link_future(future)
return await future
async def publish_message(
self,
message: Any,
topic_id: TopicId,
*,
sender: AgentId | None = None,
cancellation_token: CancellationToken | None = None,
message_id: str | None = None,
) -> None:
"""Publish a message to all agents that are subscribed to the topic."""
with self._tracer_helper.trace_block(
"create",
topic_id,
parent=None,
extraAttributes={"message_type": type(message).__name__},
):
if cancellation_token is None:
cancellation_token = CancellationToken()
content = message.__dict__ if hasattr(message, "__dict__") else message
logger.info(f"Publishing message of type {type(message).__name__} to all subscribers: {content}")
if message_id is None:
message_id = str(uuid.uuid4())
event_logger.info(
MessageEvent(
payload=self._try_serialize(message),
sender=sender,
receiver=topic_id,
kind=MessageKind.PUBLISH,
delivery_stage=DeliveryStage.SEND,
)
)
await self._message_queue.put(
PublishMessageEnvelope(
message=message,
cancellation_token=cancellation_token,
sender=sender,
topic_id=topic_id,
metadata=get_telemetry_envelope_metadata(),
message_id=message_id,
)
)
async def save_state(self) -> Mapping[str, Any]:
"""Save the state of all instantiated agents.
This method calls the :meth:`~agent_runtime.BaseAgent.save_state` method on each agent and returns a dictionary
mapping agent IDs to their state.
.. note::
This method does not currently save the subscription state. We will add this in the future.
Returns:
A dictionary mapping agent IDs to their state.
"""
state: dict[str, dict[str, Any]] = {}
for agent_id in self._instantiated_agents:
state[str(agent_id)] = dict(await (await self._get_agent(agent_id)).save_state())
return state
async def load_state(self, state: Mapping[str, Any]) -> None:
"""Load the state of all instantiated agents.
This method calls the :meth:`~agent_runtime.BaseAgent.load_state` method on each agent with the state
provided in the dictionary. The keys of the dictionary are the agent IDs, and the values are the state
dictionaries returned by the :meth:`~agent_runtime.BaseAgent.save_state` method.
.. note::
This method does not currently load the subscription state. We will add this in the future.
"""
for agent_id_str in state:
agent_id = CoreAgentId.from_str(agent_id_str)
if agent_id.type in self._known_agent_names:
await (await self._get_agent(agent_id)).load_state(state[str(agent_id)])
async def _process_send(self, message_envelope: SendMessageEnvelope) -> None:
with self._tracer_helper.trace_block("send", message_envelope.recipient, parent=message_envelope.metadata):
recipient = message_envelope.recipient
if recipient.type not in self._known_agent_names:
raise LookupError(f"Agent type '{recipient.type}' does not exist.")
try:
sender_id = str(message_envelope.sender) if message_envelope.sender is not None else "Unknown"
logger.info(
f"Calling message handler for {recipient} with message type "
f"{type(message_envelope.message).__name__} sent by {sender_id}"
)
event_logger.info(
MessageEvent(
payload=self._try_serialize(message_envelope.message),
sender=message_envelope.sender,
receiver=recipient,
kind=MessageKind.DIRECT,
delivery_stage=DeliveryStage.DELIVER,
)
)
recipient_agent = await self._get_agent(recipient)
message_context = MessageContext(
sender=message_envelope.sender,
topic_id=None,
is_rpc=True,
cancellation_token=message_envelope.cancellation_token,
message_id=message_envelope.message_id,
)
with (
self._tracer_helper.trace_block("process", recipient_agent.id, parent=message_envelope.metadata),
MessageHandlerContext.populate_context(recipient_agent.id),
):
response = await recipient_agent.on_message(
message_envelope.message,
ctx=message_context,
)
except CancelledError as e:
if not message_envelope.future.cancelled():
message_envelope.future.set_exception(e)
self._message_queue.task_done()
event_logger.info(
MessageHandlerExceptionEvent(
payload=self._try_serialize(message_envelope.message),
handling_agent=recipient,
exception=e,
)
)
return
except BaseException as e:
message_envelope.future.set_exception(e)
self._message_queue.task_done()
event_logger.info(
MessageHandlerExceptionEvent(
payload=self._try_serialize(message_envelope.message),
handling_agent=recipient,
exception=e,
)
)
return
event_logger.info(
MessageEvent(
payload=self._try_serialize(response),
sender=message_envelope.recipient,
receiver=message_envelope.sender,
kind=MessageKind.RESPOND,
delivery_stage=DeliveryStage.SEND,
)
)
await self._message_queue.put(
ResponseMessageEnvelope(
message=response,
future=message_envelope.future,
sender=message_envelope.recipient,
recipient=message_envelope.sender,
metadata=get_telemetry_envelope_metadata(),
)
)
self._message_queue.task_done()
async def _process_publish(self, message_envelope: PublishMessageEnvelope) -> None:
with self._tracer_helper.trace_block("publish", message_envelope.topic_id, parent=message_envelope.metadata):
try:
responses: list[Awaitable[Any]] = []
recipients = await self._subscription_manager.get_subscribed_recipients(message_envelope.topic_id)
for agent_id in recipients:
# Avoid sending the message back to the sender
if message_envelope.sender is not None and agent_id == message_envelope.sender:
continue
sender_agent = (
await self._get_agent(message_envelope.sender) if message_envelope.sender is not None else None
)
sender_name = str(sender_agent.id) if sender_agent is not None else "Unknown"
logger.info(
f"Calling message handler for {agent_id.type} with message type "
f"{type(message_envelope.message).__name__} published by {sender_name}"
)
event_logger.info(
MessageEvent(
payload=self._try_serialize(message_envelope.message),
sender=message_envelope.sender,
receiver=None,
kind=MessageKind.PUBLISH,
delivery_stage=DeliveryStage.DELIVER,
)
)
message_context = MessageContext(
sender=message_envelope.sender,
topic_id=message_envelope.topic_id,
is_rpc=False,
cancellation_token=message_envelope.cancellation_token,
message_id=message_envelope.message_id,
)
agent = await self._get_agent(agent_id)
async def _on_message(agent: Agent, message_context: MessageContext) -> Any:
with (
self._tracer_helper.trace_block("process", agent.id, parent=message_envelope.metadata),
MessageHandlerContext.populate_context(agent.id),
):
try:
return await agent.on_message(
message_envelope.message,
ctx=message_context,
)
except BaseException as e:
logger.error(f"Error processing publish message for {agent.id}", exc_info=True)
event_logger.info(
MessageHandlerExceptionEvent(
payload=self._try_serialize(message_envelope.message),
handling_agent=agent.id,
exception=e,
)
)
raise e
future = _on_message(agent, message_context)
responses.append(future)
await asyncio.gather(*responses)
except BaseException as e:
if not self._ignore_unhandled_handler_exceptions:
self._background_exception = e
finally:
self._message_queue.task_done()
# TODO(evmattso): if responses are given for a publish
async def _process_response(self, message_envelope: ResponseMessageEnvelope) -> None:
with self._tracer_helper.trace_block("ack", message_envelope.recipient, parent=message_envelope.metadata):
content = (
message_envelope.message.__dict__
if hasattr(message_envelope.message, "__dict__")
else message_envelope.message
)
logger.info(
f"Resolving response with message type {type(message_envelope.message).__name__} for recipient "
f"{message_envelope.recipient} from {message_envelope.sender.type}: {content}"
)
event_logger.info(
MessageEvent(
payload=self._try_serialize(message_envelope.message),
sender=message_envelope.sender,
receiver=message_envelope.recipient,
kind=MessageKind.RESPOND,
delivery_stage=DeliveryStage.DELIVER,
)
)
if not message_envelope.future.cancelled():
message_envelope.future.set_result(message_envelope.message)
self._message_queue.task_done()
async def process_next(self) -> None:
"""Process the next message in the queue.
If there is an unhandled exception in the background task, it will be raised here. `process_next` cannot be
called again after an unhandled exception is raised.
"""
await self._process_next()
async def _process_next(self) -> None:
"""Process the next message in the queue."""
if self._background_exception is not None:
e = self._background_exception
self._background_exception = None
self._message_queue.shutdown(immediate=True) # type: ignore
raise e
try:
message_envelope = await self._message_queue.get()
except QueueShutDown:
if self._background_exception is not None:
e = self._background_exception
self._background_exception = None
raise e from None
return
match message_envelope:
case SendMessageEnvelope(message=message, sender=sender, recipient=recipient, future=future):
if self._intervention_handlers is not None:
for handler in self._intervention_handlers:
with self._tracer_helper.trace_block(
"intercept", handler.__class__.__name__, parent=message_envelope.metadata
):
try:
message_context = MessageContext(
sender=sender,
topic_id=None,
is_rpc=True,
cancellation_token=message_envelope.cancellation_token,
message_id=message_envelope.message_id,
)
temp_message = await handler.on_send(
message, message_context=message_context, recipient=recipient
)
_warn_if_none(temp_message, "on_send")
except BaseException as e:
future.set_exception(e)
return
if temp_message is DropMessage or isinstance(temp_message, DropMessage):
event_logger.info(
MessageDroppedEvent(
payload=self._try_serialize(message),
sender=sender,
receiver=recipient,
kind=MessageKind.DIRECT,
)
)
future.set_exception(MessageDroppedException())
return
message_envelope.message = temp_message
task = asyncio.create_task(self._process_send(message_envelope))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
case PublishMessageEnvelope(
message=message,
sender=sender,
topic_id=topic_id,
):
if self._intervention_handlers is not None:
for handler in self._intervention_handlers:
with self._tracer_helper.trace_block(
"intercept", handler.__class__.__name__, parent=message_envelope.metadata
):
try:
message_context = MessageContext(
sender=sender,
topic_id=topic_id,
is_rpc=False,
cancellation_token=message_envelope.cancellation_token,
message_id=message_envelope.message_id,
)
temp_message = await handler.on_publish(message, message_context=message_context)
_warn_if_none(temp_message, "on_publish")
except BaseException as e:
# TODO(evmattso): we should raise the intervention exception to the publisher.
logger.error(f"Exception raised in in intervention handler: {e}", exc_info=True)
return
if temp_message is DropMessage or isinstance(temp_message, DropMessage):
event_logger.info(
MessageDroppedEvent(
payload=self._try_serialize(message),
sender=sender,
receiver=topic_id,
kind=MessageKind.PUBLISH,
)
)
return
message_envelope.message = temp_message
task = asyncio.create_task(self._process_publish(message_envelope))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
case ResponseMessageEnvelope(message=message, sender=sender, recipient=recipient, future=future):
if self._intervention_handlers is not None:
for handler in self._intervention_handlers:
try:
temp_message = await handler.on_response(message, sender=sender, recipient=recipient)
_warn_if_none(temp_message, "on_response")
except BaseException as e:
# TODO(evmattso): should we raise the exception to sender of the response instead?
future.set_exception(e)
return
if temp_message is DropMessage or isinstance(temp_message, DropMessage):
event_logger.info(
MessageDroppedEvent(
payload=self._try_serialize(message),
sender=sender,
receiver=recipient,
kind=MessageKind.RESPOND,
)
)
future.set_exception(MessageDroppedException())
return
message_envelope.message = temp_message
task = asyncio.create_task(self._process_response(message_envelope))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
# Yield control to the message loop to allow other tasks to run
await asyncio.sleep(0)
def start(self) -> None:
"""Start the runtime message processing loop. This runs in a background task."""
if self._run_context is not None:
raise RuntimeError("Runtime is already started")
self._run_context = RunContext(self)
async def close(self) -> None:
"""Calls :meth:`stop` if applicable and the :meth:`Agent.close` method on all instantiated agents."""
# stop the runtime if it hasn't been stopped yet
if self._run_context is not None:
await self.stop()
# close all the agents that have been instantiated
for agent_id in self._instantiated_agents:
agent = await self._get_agent(agent_id)
await agent.close()
async def stop(self) -> None:
"""Immediately stop the runtime message processing loop.
The currently processing message will be completed, but all others following it will be discarded.
"""
if self._run_context is None:
raise RuntimeError("Runtime is not started")
try:
await self._run_context.stop()
finally:
self._run_context = None
self._message_queue = Queue()
async def stop_when_idle(self) -> None:
"""Stop the runtime message processing loop when there is no outstanding message being processed or queued.
This is the most common way to stop the runtime.
"""
if self._run_context is None:
raise RuntimeError("Runtime is not started")
try:
await self._run_context.stop_when_idle()
finally:
self._run_context = None
self._message_queue = Queue()
async def stop_when(self, condition: Callable[[], bool]) -> None:
"""Stop the runtime message processing loop when the condition is met.
.. caution::
This method is not recommended to be used, and is here for legacy
reasons. It will spawn a busy loop to continually check the
condition. It is much more efficient to call `stop_when_idle` or
`stop` instead. If you need to stop the runtime based on a
condition, consider using a background task and asyncio.Event to
signal when the condition is met and the background task should call
stop.
"""
if self._run_context is None:
raise RuntimeError("Runtime is not started")
await self._run_context.stop_when(condition)
self._run_context = None
self._message_queue = Queue()
async def agent_metadata(self, agent: AgentId) -> AgentMetadata:
"""Get the metadata for an agent."""
return (await self._get_agent(agent)).metadata
async def agent_save_state(self, agent: AgentId) -> Mapping[str, Any]:
"""Save the state of a single agent."""
return await (await self._get_agent(agent)).save_state()
async def agent_load_state(self, agent: AgentId, state: Mapping[str, Any]) -> None:
"""Load the state of a single agent."""
await (await self._get_agent(agent)).load_state(state)
async def register_factory(
self,
type: str | AgentType,
agent_factory: Callable[[], T | Awaitable[T]],
*,
expected_class: type[T] | None = None,
) -> AgentType:
"""Register a factory for creating agents."""
if isinstance(type, str):
type = CoreAgentType(type)
if type.type in self._agent_factories:
raise ValueError(f"Agent with type {type} already exists.")
async def factory_wrapper() -> T:
maybe_agent_instance = agent_factory()
if inspect.isawaitable(maybe_agent_instance):
agent_instance = await maybe_agent_instance
else:
agent_instance = maybe_agent_instance
if expected_class is not None and type_func_alias(agent_instance) != expected_class:
raise ValueError("Factory registered using the wrong type.")
return agent_instance
self._agent_factories[type.type] = factory_wrapper
return type
async def _invoke_agent_factory(
self,
agent_factory: Callable[[], T | Awaitable[T]] | Callable[[CoreRuntime, AgentId], T | Awaitable[T]],
agent_id: AgentId,
) -> T:
with AgentInstantiationContext.populate_context((self, agent_id)):
try:
if len(inspect.signature(agent_factory).parameters) == 0:
factory_one = cast(Callable[[], T], agent_factory)
agent = factory_one()
elif len(inspect.signature(agent_factory).parameters) == 2:
warnings.warn(
"Agent factories that take two arguments are deprecated. Use AgentInstantiationContext "
"instead. Two arg factories will be removed in a future version.",
stacklevel=2,
)
factory_two = cast(Callable[[CoreRuntime, AgentId], T], agent_factory)
agent = factory_two(self, agent_id)
else:
raise ValueError("Agent factory must take 0 or 2 arguments.")
if inspect.isawaitable(agent):
return cast(T, await agent)
return agent
except BaseException as e:
event_logger.info(
AgentConstructionExceptionEvent(
agent_id=agent_id,
exception=e,
)
)
logger.error(f"Error constructing agent {agent_id}", exc_info=True)
raise
async def _get_agent(self, agent_id: AgentId) -> Agent:
if agent_id in self._instantiated_agents:
return self._instantiated_agents[agent_id]
if agent_id.type not in self._agent_factories:
raise LookupError(f"Agent with name {agent_id.type} not found.")
agent_factory = self._agent_factories[agent_id.type]
agent = await self._invoke_agent_factory(agent_factory, agent_id)
self._instantiated_agents[agent_id] = agent
return agent
# TODO(evmattso): uncomment out the following type ignore when this is fixed in mypy: https://github.com/python/mypy/issues/3737
async def try_get_underlying_agent_instance(self, id: AgentId, type: type[T] = Agent) -> T: # type: ignore[assignment]
"""Try to get the underlying agent instance by name and namespace."""
if id.type not in self._agent_factories:
raise LookupError(f"Agent with name {id.type} not found.")
# TODO(evmattso): check if remote
agent_instance = await self._get_agent(id)
if not isinstance(agent_instance, type):
raise TypeError(
f"Agent with name {id.type} is not of type {type.__name__}. "
f"It is of type {type_func_alias(agent_instance).__name__}"
)
return agent_instance
async def add_subscription(self, subscription: Subscription) -> None:
"""Add a subscription to the runtime."""
await self._subscription_manager.add_subscription(subscription)
async def remove_subscription(self, id: str) -> None:
"""Remove a subscription from the runtime."""
await self._subscription_manager.remove_subscription(id)
async def get(
self, id_or_type: AgentId | AgentType | str, /, key: str = "default", *, lazy: bool = True
) -> AgentId:
"""Get an agent by id or type."""
return await get_impl(
id_or_type=id_or_type,
key=key,
lazy=lazy,
instance_getter=self._get_agent,
)
def add_message_serializer(self, serializer: MessageSerializer[Any] | Sequence[MessageSerializer[Any]]) -> None:
"""Add a message serializer to the runtime."""
self._serialization_registry.add_serializer(serializer)
def _try_serialize(self, message: Any) -> str:
try:
type_name = self._serialization_registry.type_name(message)
return self._serialization_registry.serialize(
message, type_name=type_name, data_content_type=JSON_DATA_CONTENT_TYPE
).decode("utf-8")
except ValueError:
return "Message could not be serialized"
@@ -0,0 +1,41 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, ClassVar
from semantic_kernel.agents.runtime.core.agent_id import AgentId
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class MessageHandlerContext:
"""Context for message handlers."""
def __init__(self) -> None:
"""Instantiate the MessageHandlerContext class."""
raise RuntimeError(
"MessageHandlerContext cannot be instantiated. It is a static class that provides context management for "
"message handling."
)
_MESSAGE_HANDLER_CONTEXT: ClassVar[ContextVar[AgentId]] = ContextVar("_MESSAGE_HANDLER_CONTEXT")
@classmethod
@contextmanager
def populate_context(cls, ctx: AgentId) -> Generator[None, Any, None]:
"""Populate the context with the current agent ID."""
token = MessageHandlerContext._MESSAGE_HANDLER_CONTEXT.set(ctx)
try:
yield
finally:
MessageHandlerContext._MESSAGE_HANDLER_CONTEXT.reset(token)
@classmethod
def agent_id(cls) -> AgentId:
"""Get the current agent ID."""
try:
return cls._MESSAGE_HANDLER_CONTEXT.get()
except LookupError as e:
raise RuntimeError("MessageHandlerContext.agent_id() must be called within a message handler.") from e
@@ -0,0 +1,274 @@
# Copyright (c) Microsoft. All rights reserved.
# Copy of Asyncio queue: https://github.com/python/cpython/blob/main/Lib/asyncio/queues.py
# So that shutdown can be used in <3.13
# Modified to work outside of the asyncio package
import asyncio
import collections
import threading
from typing import Generic, TypeVar
from semantic_kernel.utils.feature_stage_decorator import experimental
_global_lock = threading.Lock()
class _LoopBoundMixin:
_loop = None
def _get_loop(self) -> asyncio.AbstractEventLoop:
loop = asyncio.get_running_loop()
if self._loop is None:
with _global_lock:
if self._loop is None:
self._loop = loop
if loop is not self._loop:
raise RuntimeError(f"{self!r} is bound to a different event loop")
return loop
@experimental
class QueueShutDown(Exception):
"""Raised when putting on to or getting from a shut-down Queue."""
pass
T = TypeVar("T")
@experimental
class Queue(_LoopBoundMixin, Generic[T]):
"""A queue class that supports async operations."""
def __init__(self, maxsize: int = 0):
"""Initialize the queue."""
self._maxsize = maxsize
self._getters = collections.deque[asyncio.Future[None]]()
self._putters = collections.deque[asyncio.Future[None]]()
self._unfinished_tasks = 0
self._finished = asyncio.Event()
self._finished.set()
self._queue = collections.deque[T]()
self._is_shutdown = False
# These three are overridable in subclasses.
def _get(self) -> T:
return self._queue.popleft()
def _put(self, item: T) -> None:
self._queue.append(item)
# End of the overridable methods.
def _wakeup_next(self, waiters: collections.deque[asyncio.Future[None]]) -> None:
# Wake up the next waiter (if any) that isn't cancelled.
while waiters:
waiter = waiters.popleft()
if not waiter.done():
waiter.set_result(None)
break
def __repr__(self) -> str:
"""Generate a string representation of the Queue."""
return f"<{type(self).__name__} at {id(self):#x} {self._format()}>"
def __str__(self) -> str:
"""Convert the Queue to a string."""
return f"<{type(self).__name__} {self._format()}>"
def _format(self) -> str:
result = f"maxsize={self._maxsize!r}"
if getattr(self, "_queue", None):
result += f" _queue={list(self._queue)!r}"
if self._getters:
result += f" _getters[{len(self._getters)}]"
if self._putters:
result += f" _putters[{len(self._putters)}]"
if self._unfinished_tasks:
result += f" tasks={self._unfinished_tasks}"
if self._is_shutdown:
result += " shutdown"
return result
def qsize(self) -> int:
"""Number of items in the queue."""
return len(self._queue)
@property
def maxsize(self) -> int:
"""Number of items allowed in the queue."""
return self._maxsize
def empty(self) -> bool:
"""Return True if the queue is empty, False otherwise."""
return not self._queue
def full(self) -> bool:
"""Return True if there are maxsize items in the queue.
Note: if the Queue was initialized with maxsize=0 (the default),
then full() is never True.
"""
if self._maxsize <= 0:
return False
return self.qsize() >= self._maxsize
async def put(self, item: T) -> None:
"""Put an item into the queue.
Put an item into the queue. If the queue is full, wait until a free
slot is available before adding item.
Raises QueueShutDown if the queue has been shut down.
"""
while self.full():
if self._is_shutdown:
raise QueueShutDown
putter = self._get_loop().create_future()
self._putters.append(putter)
try:
await putter
except:
putter.cancel() # Just in case putter is not done yet.
try: # noqa: SIM105
# Clean self._putters from canceled putters.
self._putters.remove(putter)
except ValueError:
# The putter could be removed from self._putters by a
# previous get_nowait call or a shutdown call.
pass
if not self.full() and not putter.cancelled():
# We were woken up by get_nowait(), but can't take
# the call. Wake up the next in line.
self._wakeup_next(self._putters)
raise
return self.put_nowait(item)
def put_nowait(self, item: T) -> None:
"""Put an item into the queue without blocking.
If no free slot is immediately available, raise QueueFull.
Raises QueueShutDown if the queue has been shut down.
"""
if self._is_shutdown:
raise QueueShutDown
if self.full():
raise asyncio.QueueFull
self._put(item)
self._unfinished_tasks += 1
self._finished.clear()
self._wakeup_next(self._getters)
async def get(self) -> T:
"""Remove and return an item from the queue.
If queue is empty, wait until an item is available.
Raises QueueShutDown if the queue has been shut down and is empty, or
if the queue has been shut down immediately.
"""
while self.empty():
if self._is_shutdown and self.empty():
raise QueueShutDown
getter = self._get_loop().create_future()
self._getters.append(getter)
try:
await getter
except:
getter.cancel() # Just in case getter is not done yet.
try: # noqa: SIM105
# Clean self._getters from canceled getters.
self._getters.remove(getter)
except ValueError:
# The getter could be removed from self._getters by a
# previous put_nowait call, or a shutdown call.
pass
if not self.empty() and not getter.cancelled():
# We were woken up by put_nowait(), but can't take
# the call. Wake up the next in line.
self._wakeup_next(self._getters)
raise
return self.get_nowait()
def get_nowait(self) -> T:
"""Remove and return an item from the queue.
Return an item if one is immediately available, else raise QueueEmpty.
Raises QueueShutDown if the queue has been shut down and is empty, or
if the queue has been shut down immediately.
"""
if self.empty():
if self._is_shutdown:
raise QueueShutDown
raise asyncio.QueueEmpty
item = self._get()
self._wakeup_next(self._putters)
return item
def task_done(self) -> None:
"""Indicate that a formerly enqueued task is complete.
Used by queue consumers. For each get() used to fetch a task,
a subsequent call to task_done() tells the queue that the processing
on the task is complete.
If a join() is currently blocking, it will resume when all items have
been processed (meaning that a task_done() call was received for every
item that had been put() into the queue).
shutdown(immediate=True) calls task_done() for each remaining item in
the queue.
Raises ValueError if called more times than there were items placed in
the queue.
"""
if self._unfinished_tasks <= 0:
raise ValueError("task_done() called too many times")
self._unfinished_tasks -= 1
if self._unfinished_tasks == 0:
self._finished.set()
async def join(self) -> None:
"""Block until all items in the queue have been gotten and processed.
The count of unfinished tasks goes up whenever an item is added to the
queue. The count goes down whenever a consumer calls task_done() to
indicate that the item was retrieved and all work on it is complete.
When the count of unfinished tasks drops to zero, join() unblocks.
"""
if self._unfinished_tasks > 0:
await self._finished.wait()
def shutdown(self, immediate: bool = False) -> None:
"""Shut-down the queue, making queue gets and puts raise QueueShutDown.
By default, gets will only raise once the queue is empty. Set
'immediate' to True to make gets raise immediately instead.
All blocked callers of put() and get() will be unblocked. If
'immediate', a task is marked as done for each item remaining in
the queue, which may unblock callers of join().
"""
self._is_shutdown = True
if immediate:
while not self.empty():
self._get()
if self._unfinished_tasks > 0:
self._unfinished_tasks -= 1
if self._unfinished_tasks == 0:
self._finished.set()
# All getters need to re-check queue-empty to raise ShutDown
while self._getters:
getter = self._getters.popleft()
if not getter.done():
getter.set_result(None)
while self._putters:
putter = self._putters.popleft()
if not putter.done():
putter.set_result(None)
@@ -0,0 +1,94 @@
# Copyright (c) Microsoft. All rights reserved.
from collections import defaultdict
from collections.abc import Awaitable, Callable, Sequence
from typing import DefaultDict
from semantic_kernel.agents.runtime.core.agent import Agent
from semantic_kernel.agents.runtime.core.agent_id import AgentId, CoreAgentId
from semantic_kernel.agents.runtime.core.agent_type import AgentType
from semantic_kernel.agents.runtime.core.subscription import Subscription
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
async def get_impl(
*,
id_or_type: AgentId | AgentType | str,
key: str,
lazy: bool,
instance_getter: Callable[[AgentId], Awaitable[Agent]],
) -> AgentId:
"""Get the implementation of an agent."""
if isinstance(id_or_type, AgentId):
if not lazy:
await instance_getter(id_or_type)
return id_or_type
type_str = id_or_type if isinstance(id_or_type, str) else id_or_type.type
id = CoreAgentId(type_str, key)
if not lazy:
await instance_getter(id)
return id
@experimental
class SubscriptionManager:
"""Manages subscriptions for agents."""
def __init__(self) -> None:
"""Initialize the SubscriptionManager."""
self._subscriptions: list[Subscription] = []
self._seen_topics: set[TopicId] = set()
self._subscribed_recipients: DefaultDict[TopicId, list[AgentId]] = defaultdict(list)
@property
def subscriptions(self) -> Sequence[Subscription]:
"""Get the list of subscriptions."""
return self._subscriptions
async def add_subscription(self, subscription: Subscription) -> None:
"""Add a subscription to the manager."""
# Check if the subscription already exists
if any(sub == subscription for sub in self._subscriptions):
raise ValueError("Subscription already exists")
self._subscriptions.append(subscription)
self._rebuild_subscriptions(self._seen_topics)
async def remove_subscription(self, id: str) -> None:
"""Remove a subscription from the manager."""
# Check if the subscription exists
if not any(sub.id == id for sub in self._subscriptions):
raise ValueError("Subscription does not exist")
def is_not_sub(x: Subscription) -> bool:
return x.id != id
self._subscriptions = list(filter(is_not_sub, self._subscriptions))
# Rebuild the subscriptions
self._rebuild_subscriptions(self._seen_topics)
async def get_subscribed_recipients(self, topic: TopicId) -> list[AgentId]:
"""Get the list of recipients subscribed to a topic."""
if topic not in self._seen_topics:
self._build_for_new_topic(topic)
return self._subscribed_recipients[topic]
# TODO(evmattso): optimize this...
def _rebuild_subscriptions(self, topics: set[TopicId]) -> None:
"""Rebuild the subscriptions for the given topics."""
self._subscribed_recipients.clear()
for topic in topics:
self._build_for_new_topic(topic)
def _build_for_new_topic(self, topic: TopicId) -> None:
"""Build the subscriptions for a new topic."""
self._seen_topics.add(topic)
for subscription in self._subscriptions:
if subscription.is_match(topic):
self._subscribed_recipients[topic].append(subscription.map_to_agent(topic))
@@ -0,0 +1,45 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, ClassVar
from semantic_kernel.agents.runtime.core.agent_type import AgentType
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class SubscriptionInstantiationContext:
"""Context manager for subscription instantiation."""
def __init__(self) -> None:
"""Prevent instantiation of SubscriptionInstantiationContext."""
raise RuntimeError(
"SubscriptionInstantiationContext cannot be instantiated. It is a static class that provides context "
"management for subscription instantiation."
)
_SUBSCRIPTION_CONTEXT_VAR: ClassVar[ContextVar[AgentType]] = ContextVar("_SUBSCRIPTION_CONTEXT_VAR")
@classmethod
@contextmanager
def populate_context(cls, ctx: AgentType) -> Generator[None, Any, None]:
"""Populate the context with the agent type."""
token = SubscriptionInstantiationContext._SUBSCRIPTION_CONTEXT_VAR.set(ctx)
try:
yield
finally:
SubscriptionInstantiationContext._SUBSCRIPTION_CONTEXT_VAR.reset(token)
@classmethod
def agent_type(cls) -> AgentType:
"""Get the agent type from the context."""
try:
return cls._SUBSCRIPTION_CONTEXT_VAR.get()
except LookupError as e:
raise RuntimeError(
"SubscriptionInstantiationContext.runtime() must be called within an instantiation context such as "
"when the AgentRuntime is instantiating an agent. Mostly likely this was caused by directly "
"instantiating an agent instead of using the AgentRuntime to do so."
) from e
@@ -0,0 +1,67 @@
# Copyright (c) Microsoft. All rights reserved.
import uuid
from semantic_kernel.agents.runtime.core.agent_id import AgentId, CoreAgentId
from semantic_kernel.agents.runtime.core.agent_type import AgentType
from semantic_kernel.agents.runtime.core.exceptions import CantHandleException
from semantic_kernel.agents.runtime.core.subscription import Subscription
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class TypePrefixSubscription(Subscription):
"""This subscription matches on topics based on a prefix of the type and maps to agents.
It uses the source of the topic as the agent key. This subscription causes each source to have
its own agent instance.
Args:
topic_type_prefix (str): Topic type prefix to match against
agent_type (str): Agent type to handle this subscription
"""
def __init__(self, topic_type_prefix: str, agent_type: str | AgentType, id: str | None = None):
"""Initialize the TypePrefixSubscription."""
self._topic_type_prefix = topic_type_prefix
if isinstance(agent_type, AgentType):
self._agent_type = agent_type.type
else:
self._agent_type = agent_type
self._id = id or str(uuid.uuid4())
@property
def id(self) -> str:
"""Get the id of the subscription."""
return self._id
@property
def topic_type_prefix(self) -> str:
"""Get the topic type prefix of the subscription."""
return self._topic_type_prefix
@property
def agent_type(self) -> str:
"""Get the agent type of the subscription."""
return self._agent_type
def is_match(self, topic_id: TopicId) -> bool:
"""Check if the topic_id matches the subscription."""
return topic_id.type.startswith(self._topic_type_prefix)
def map_to_agent(self, topic_id: TopicId) -> AgentId:
"""Map the topic_id to an agent_id."""
if not self.is_match(topic_id):
raise CantHandleException("TopicId does not match the subscription")
return CoreAgentId(type=self._agent_type, key=topic_id.source)
def __eq__(self, other: object) -> bool:
"""Check if two subscriptions are equal."""
if not isinstance(other, TypePrefixSubscription):
return False
return self.id == other.id or (
self.agent_type == other.agent_type and self.topic_type_prefix == other.topic_type_prefix
)
@@ -0,0 +1,67 @@
# Copyright (c) Microsoft. All rights reserved.
import uuid
from semantic_kernel.agents.runtime.core.agent_id import AgentId, CoreAgentId
from semantic_kernel.agents.runtime.core.agent_type import AgentType
from semantic_kernel.agents.runtime.core.exceptions import CantHandleException
from semantic_kernel.agents.runtime.core.subscription import Subscription
from semantic_kernel.agents.runtime.core.topic import TopicId
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class TypeSubscription(Subscription):
"""This subscription matches on topics based on the type and maps to agents.
It uses the source of the topic as the agent key. This subscription causes each source to have
its own agent instance.
"""
def __init__(self, topic_type: str, agent_type: str | AgentType, id: str | None = None):
"""Initialize the TypeSubscription.
Args:
topic_type (str): Topic type to match against
agent_type (str): Agent type to handle this subscription
id (str | None): Id of the subscription. If None, a new id will be generated.
"""
self._topic_type = topic_type
if isinstance(agent_type, AgentType):
self._agent_type = agent_type.type
else:
self._agent_type = agent_type
self._id = id or str(uuid.uuid4())
@property
def id(self) -> str:
"""Get the id of the subscription."""
return self._id
@property
def topic_type(self) -> str:
"""Get the topic type of the subscription."""
return self._topic_type
@property
def agent_type(self) -> str:
"""Get the agent type of the subscription."""
return self._agent_type
def is_match(self, topic_id: TopicId) -> bool:
"""Check if the topic_id matches the subscription."""
return topic_id.type == self._topic_type
def map_to_agent(self, topic_id: TopicId) -> AgentId:
"""Map the topic_id to an agent_id."""
if not self.is_match(topic_id):
raise CantHandleException("TopicId does not match the subscription")
return CoreAgentId(type=self._agent_type, key=topic_id.source)
def __eq__(self, other: object) -> bool:
"""Check if two subscriptions are equal."""
if not isinstance(other, TypeSubscription):
return False
return self.id == other.id or (self.agent_type == other.agent_type and self.topic_type == other.topic_type)
@@ -0,0 +1,23 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.agents.strategies.selection.kernel_function_selection_strategy import (
KernelFunctionSelectionStrategy,
)
from semantic_kernel.agents.strategies.selection.selection_strategy import SelectionStrategy
from semantic_kernel.agents.strategies.selection.sequential_selection_strategy import SequentialSelectionStrategy
from semantic_kernel.agents.strategies.termination.aggregator_termination_strategy import AggregatorTerminationStrategy
from semantic_kernel.agents.strategies.termination.default_termination_strategy import DefaultTerminationStrategy
from semantic_kernel.agents.strategies.termination.kernel_function_termination_strategy import (
KernelFunctionTerminationStrategy,
)
from semantic_kernel.agents.strategies.termination.termination_strategy import TerminationStrategy
__all__ = [
"AggregatorTerminationStrategy",
"DefaultTerminationStrategy",
"KernelFunctionSelectionStrategy",
"KernelFunctionTerminationStrategy",
"SelectionStrategy",
"SequentialSelectionStrategy",
"TerminationStrategy",
]
@@ -0,0 +1,116 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import sys
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
from collections.abc import Callable
from inspect import isawaitable
from typing import TYPE_CHECKING, ClassVar
from pydantic import Field
from semantic_kernel.agents.strategies.selection.selection_strategy import SelectionStrategy
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.history_reducer.chat_history_reducer import ChatHistoryReducer
from semantic_kernel.exceptions.agent_exceptions import AgentExecutionException
from semantic_kernel.functions.kernel_arguments import KernelArguments
from semantic_kernel.functions.kernel_function import KernelFunction
from semantic_kernel.kernel import Kernel
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.agents import Agent
logger: logging.Logger = logging.getLogger(__name__)
@experimental
class KernelFunctionSelectionStrategy(SelectionStrategy):
"""Determines agent selection based on the evaluation of a Kernel Function."""
DEFAULT_AGENT_VARIABLE_NAME: ClassVar[str] = "_agent_"
DEFAULT_HISTORY_VARIABLE_NAME: ClassVar[str] = "_history_"
agent_variable_name: str | None = Field(default=DEFAULT_AGENT_VARIABLE_NAME)
history_variable_name: str | None = Field(default=DEFAULT_HISTORY_VARIABLE_NAME)
arguments: KernelArguments | None = None
function: KernelFunction
kernel: Kernel
result_parser: Callable[..., str] = Field(default_factory=lambda: (lambda: ""))
history_reducer: ChatHistoryReducer | None = None
@override
async def select_agent(self, agents: list["Agent"], history: list[ChatMessageContent]) -> "Agent":
"""Select the next agent to interact with.
Args:
agents: The list of agents to select from.
history: The history of messages in the conversation.
Returns:
The next agent to interact with.
Raises:
AgentExecutionException: If the strategy fails to execute the function or select the next agent
"""
if self.history_reducer is not None:
self.history_reducer.messages = history
reduced_history = await self.history_reducer.reduce()
if reduced_history is not None:
history = reduced_history.messages
original_arguments = self.arguments or KernelArguments()
execution_settings = original_arguments.execution_settings or {}
messages = [message.to_dict(role_key="role", content_key="content") for message in history]
filtered_arguments = {
self.agent_variable_name: ",".join(agent.name for agent in agents),
self.history_variable_name: messages,
}
extracted_settings = {key: setting.model_dump() for key, setting in execution_settings.items()}
combined_arguments = {
**original_arguments,
**extracted_settings,
**{k: v for k, v in filtered_arguments.items()},
}
arguments = KernelArguments(
**combined_arguments,
)
logger.info(
f"Kernel Function Selection Strategy next method called, "
f"invoking function: {self.function.plugin_name}, {self.function.name}",
)
try:
result = await self.function.invoke(kernel=self.kernel, arguments=arguments)
except Exception as ex:
logger.error("Kernel Function Selection Strategy next method failed", exc_info=ex)
raise AgentExecutionException("Agent Failure - Strategy failed to execute function.") from ex
logger.info(
f"Kernel Function Selection Strategy next method completed: "
f"{self.function.plugin_name}, {self.function.name}, result: {result.value if result else None}",
)
agent_name = self.result_parser(result)
if isawaitable(agent_name):
agent_name = await agent_name
if agent_name is None:
raise AgentExecutionException("Agent Failure - Strategy unable to determine next agent.")
agent_turn = next((agent for agent in agents if agent.name == agent_name), None)
if agent_turn is None:
raise AgentExecutionException(f"Agent Failure - Strategy unable to select next agent: {agent_name}")
return agent_turn
@@ -0,0 +1,57 @@
# Copyright (c) Microsoft. All rights reserved.
from abc import ABC
from typing import TYPE_CHECKING
from semantic_kernel.agents import Agent
from semantic_kernel.exceptions.agent_exceptions import AgentExecutionException
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from semantic_kernel.contents.chat_message_content import ChatMessageContent
@experimental
class SelectionStrategy(KernelBaseModel, ABC):
"""Base strategy class for selecting the next agent in a chat."""
has_selected: bool = False
initial_agent: Agent | None = None
async def next(
self,
agents: list[Agent],
history: list["ChatMessageContent"],
) -> Agent:
"""Select the next agent to interact with.
Args:
agents: The list of agents to select from.
history: The history of messages in the conversation.
Returns:
The agent who takes the next turn.
"""
if not agents and self.initial_agent is None:
raise AgentExecutionException("Agent Failure - No agents present to select.")
# If it's the first selection and we have an initial agent, use it
if not self.has_selected and self.initial_agent is not None:
agent = self.initial_agent
else:
agent = await self.select_agent(agents, history)
self.has_selected = True
return agent
async def select_agent(
self,
agents: list[Agent],
history: list["ChatMessageContent"],
) -> Agent:
"""Determines which agent goes next. Override for custom logic.
By default, this fallback returns the first agent in the list.
"""
return agents[0]

Some files were not shown because too many files have changed in this diff Show More