218 lines
8.5 KiB
Python
218 lines
8.5 KiB
Python
# 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
|