chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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",
|
||||
]
|
||||
+116
@@ -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]
|
||||
@@ -0,0 +1,80 @@
|
||||
# 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 typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from semantic_kernel.agents.strategies.selection.selection_strategy import SelectionStrategy
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from semantic_kernel.agents import Agent
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@experimental
|
||||
class SequentialSelectionStrategy(SelectionStrategy):
|
||||
"""Round-robin turn-taking strategy. Agent order is based on the order in which they joined."""
|
||||
|
||||
_index: int = PrivateAttr(default=-1)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset selection to the initial/first agent."""
|
||||
self._index = -1
|
||||
|
||||
def _increment_index(self, agent_count: int) -> None:
|
||||
"""Increment the index in a circular manner."""
|
||||
self._index = (self._index + 1) % agent_count
|
||||
|
||||
@override
|
||||
async def select_agent(
|
||||
self,
|
||||
agents: list["Agent"],
|
||||
history: list["ChatMessageContent"],
|
||||
) -> "Agent":
|
||||
"""Select the next agent in a round-robin fashion.
|
||||
|
||||
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 self._index >= len(agents):
|
||||
self._index = -1
|
||||
|
||||
if (
|
||||
self.has_selected
|
||||
and self.initial_agent is not None
|
||||
and len(agents) > 0
|
||||
and agents[0] == self.initial_agent
|
||||
and self._index < 0
|
||||
):
|
||||
# Avoid selecting the same agent twice in a row
|
||||
self._increment_index(len(agents))
|
||||
|
||||
# Main index increment
|
||||
self._increment_index(len(agents))
|
||||
|
||||
# Pick the agent
|
||||
agent = agents[self._index]
|
||||
|
||||
logger.info(
|
||||
"Selected agent at index %d (ID: %s, name: %s)",
|
||||
self._index,
|
||||
agent.id,
|
||||
agent.name,
|
||||
)
|
||||
return agent
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user