From 32d2101a07bfdb89cf41d6928e1397d3d8b60322 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 5 Jul 2024 13:10:15 -0400 Subject: [PATCH 01/17] Add unit tests for single SK chat completion agent. --- python/samples/concepts/README.md | 1 + python/samples/concepts/agents/README.md | 29 +++ python/samples/concepts/agents/step1_agent.py | 63 +++++++ python/semantic_kernel/agents/__init__.py | 19 ++ python/semantic_kernel/agents/agent.py | 31 ++++ .../semantic_kernel/agents/agent_channel.py | 39 ++++ .../agents/chat_completion_agent.py | 170 ++++++++++++++++++ .../agents/chat_history_channel.py | 46 +++++ .../agents/chat_history_handler.py | 23 +++ .../agents/chat_history_kernel_agent.py | 58 ++++++ python/semantic_kernel/agents/kernel_agent.py | 20 +++ python/tests/samples/test_concepts.py | 2 + python/tests/unit/agents/test_agent.py | 54 ++++++ .../tests/unit/agents/test_agent_channel.py | 64 +++++++ .../unit/agents/test_chat_completion_agent.py | 159 ++++++++++++++++ .../unit/agents/test_chat_history_channel.py | 90 ++++++++++ .../agents/test_chat_history_kernel_agent.py | 55 ++++++ 17 files changed, 923 insertions(+) create mode 100644 python/samples/concepts/agents/README.md create mode 100644 python/samples/concepts/agents/step1_agent.py create mode 100644 python/semantic_kernel/agents/__init__.py create mode 100644 python/semantic_kernel/agents/agent.py create mode 100644 python/semantic_kernel/agents/agent_channel.py create mode 100644 python/semantic_kernel/agents/chat_completion_agent.py create mode 100644 python/semantic_kernel/agents/chat_history_channel.py create mode 100644 python/semantic_kernel/agents/chat_history_handler.py create mode 100644 python/semantic_kernel/agents/chat_history_kernel_agent.py create mode 100644 python/semantic_kernel/agents/kernel_agent.py create mode 100644 python/tests/unit/agents/test_agent.py create mode 100644 python/tests/unit/agents/test_agent_channel.py create mode 100644 python/tests/unit/agents/test_chat_completion_agent.py create mode 100644 python/tests/unit/agents/test_chat_history_channel.py create mode 100644 python/tests/unit/agents/test_chat_history_kernel_agent.py diff --git a/python/samples/concepts/README.md b/python/samples/concepts/README.md index 72028080bd2a..1786c5d02779 100644 --- a/python/samples/concepts/README.md +++ b/python/samples/concepts/README.md @@ -4,6 +4,7 @@ This section contains code snippets that demonstrate the usage of Semantic Kerne | Features | Description | | -------- | ----------- | +| Agents | Creating and using agents in Semantic Kernel | | AutoFunctionCalling | Using `Auto Function Calling` to allow function call capable models to invoke Kernel Functions automatically | | ChatCompletion | Using [`ChatCompletion`](https://github.com/microsoft/semantic-kernel/blob/main/python/semantic_kernel/connectors/ai/chat_completion_client_base.py) messaging capable service with models | | Filtering | Creating and using Filters | diff --git a/python/samples/concepts/agents/README.md b/python/samples/concepts/agents/README.md new file mode 100644 index 000000000000..82d1a6bebb49 --- /dev/null +++ b/python/samples/concepts/agents/README.md @@ -0,0 +1,29 @@ +# Semantic Kernel Agents - Getting Started + +This project contains a step by step guide to get started with _Semantic Kernel Agents_ in Python. + + +#### PyPI: +- For the use of agents, the minimum allowed Semantic Kernel pypi version is 1.3 # TODO Update + +#### Source +- [Semantic Kernel Agent Framework](../../../semantic_kernel/agents/) + +## Examples + +The getting started with agents examples include: + +Example|Description +---|--- +[step1_agent](../agents/step1_agent.py)|How to create and use an agent. + +## Configuring the Kernel + +Similar to the Semantic Kernel Python concept samples, it is necessary to configure the secrets +and keys used by the kernel. See the follow "Configuring the Kernel" [guide](../README.md) for +more information. + +## Running Concept Samples + +Concept samples can be run in an IDE or via the command line. After setting up the required api key +for your AI connector, the samples run without any extra command line arguments. \ No newline at end of file diff --git a/python/samples/concepts/agents/step1_agent.py b/python/samples/concepts/agents/step1_agent.py new file mode 100644 index 000000000000..49835512d384 --- /dev/null +++ b/python/samples/concepts/agents/step1_agent.py @@ -0,0 +1,63 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from semantic_kernel.agents.chat_completion_agent import ChatCompletionAgent +from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion +from semantic_kernel.contents.chat_history import ChatHistory +from semantic_kernel.contents.utils.author_role import AuthorRole +from semantic_kernel.kernel import Kernel + +################################################################### +# The following sample demonstrates how to create a simple, # +# non-group agent that repeats the user message in the voice # +# of a pirate and then ends with a parrot sound. # +################################################################### + +# To toggle streaming or non-streaming mode, change the following boolean +streaming = False + +# Define the agent name and instructions +PARROT_NAME = "Parrot" +PARROT_INSTRUCTIONS = "Repeat the user message in the voice of a pirate and then end with a parrot sound." + + +async def invoke_agent(agent: ChatCompletionAgent, input: str, kernel: Kernel, chat: ChatHistory): + """Invoke the agent with the user input.""" + chat.add_user_message(input) + + print(f"# {AuthorRole.USER}: '{input}'") + + if streaming: + contents = [] + content_name = "" + async for content in agent.invoke_streaming(kernel, chat): + content_name = content.name + contents.append(content) + print(f"# {content.role} - {content_name or '*'}: '{''.join([content.content for content in contents])}'") + else: + async for content in agent.invoke(kernel, chat): + print(f"# {content.role} - {content.name or '*'}: '{content.content}'") + + +async def main(): + # Create the instance of the Kernel + kernel = Kernel() + + # Add the OpenAIChatCompletion AI Service to the Kernel + kernel.add_service(AzureChatCompletion(service_id="agent")) + + # Create the agent + agent = ChatCompletionAgent(service_id="agent", name=PARROT_NAME, instructions=PARROT_INSTRUCTIONS) + + # Define the chat history + chat = ChatHistory() + + # Respond to user input + await invoke_agent(agent, "Fortune favors the bold.", kernel, chat) + await invoke_agent(agent, "I came, I saw, I conquered.", kernel, chat) + await invoke_agent(agent, "Practice makes perfect.", kernel, chat) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/semantic_kernel/agents/__init__.py b/python/semantic_kernel/agents/__init__.py new file mode 100644 index 000000000000..9d1a745eda4d --- /dev/null +++ b/python/semantic_kernel/agents/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft. All rights reserved. + +from semantic_kernel.agents.agent import Agent +from semantic_kernel.agents.agent_channel import AgentChannel +from semantic_kernel.agents.chat_completion_agent import ChatCompletionAgent +from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel +from semantic_kernel.agents.chat_history_handler import ChatHistoryHandler +from semantic_kernel.agents.chat_history_kernel_agent import ChatHistoryKernelAgent +from semantic_kernel.agents.kernel_agent import KernelAgent + +__all__ = [ + "Agent", + "AgentChannel", + "ChatCompletionAgent", + "ChatHistoryChannel", + "ChatHistoryHandler", + "ChatHistoryKernelAgent", + "KernelAgent", +] diff --git a/python/semantic_kernel/agents/agent.py b/python/semantic_kernel/agents/agent.py new file mode 100644 index 000000000000..c4da51b2f865 --- /dev/null +++ b/python/semantic_kernel/agents/agent.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft. All rights reserved. + +import uuid +from abc import ABC, abstractmethod + +from semantic_kernel.agents.agent_channel import AgentChannel +from semantic_kernel.kernel_pydantic import KernelBaseModel +from semantic_kernel.utils.experimental_decorator import experimental_class + + +@experimental_class +class Agent(ABC, KernelBaseModel): + id: str + description: str | None = None + name: str | None = None + + def __init__(self, name: str | None = None, description: str | None = None, id: str | None = None): + """Initialize the Agent.""" + if id is None: + id = str(uuid.uuid4()) + super().__init__(id=id, description=description, name=name) + + @abstractmethod + def get_channel_keys(self) -> list[str]: + """Get the channel keys.""" + ... + + @abstractmethod + async def create_channel(self) -> AgentChannel: + """Create a channel.""" + ... diff --git a/python/semantic_kernel/agents/agent_channel.py b/python/semantic_kernel/agents/agent_channel.py new file mode 100644 index 000000000000..4f90a24f4350 --- /dev/null +++ b/python/semantic_kernel/agents/agent_channel.py @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft. All rights reserved. + +from abc import ABC, abstractmethod +from collections.abc import AsyncIterable +from typing import TYPE_CHECKING + +from pydantic import BaseModel + +from semantic_kernel.contents.chat_message_content import ChatMessageContent +from semantic_kernel.utils.experimental_decorator import experimental_class + +if TYPE_CHECKING: + from semantic_kernel.agents.agent import Agent + + +@experimental_class +class AgentChannel(ABC, BaseModel): + @abstractmethod + async def receive( + self, + history: list[ChatMessageContent], + ) -> None: + """Receive the conversation messages.""" + pass + + @abstractmethod + async def invoke( + self, + agent: "Agent", + ) -> AsyncIterable[ChatMessageContent]: + """Perform a discrete incremental interaction between a single Agent and AgentChat.""" + pass + + @abstractmethod + async def get_history( + self, + ) -> AsyncIterable[ChatMessageContent]: + """Retrieve the message history specific to this channel.""" + pass diff --git a/python/semantic_kernel/agents/chat_completion_agent.py b/python/semantic_kernel/agents/chat_completion_agent.py new file mode 100644 index 000000000000..08cb7a044db4 --- /dev/null +++ b/python/semantic_kernel/agents/chat_completion_agent.py @@ -0,0 +1,170 @@ +# Copyright (c) Microsoft. All rights reserved. + +import logging +import sys +from collections.abc import AsyncGenerator, AsyncIterable +from typing import TYPE_CHECKING, Any, cast + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override # pragma: no cover + +from pydantic import Field + +from semantic_kernel.agents.chat_history_kernel_agent import ChatHistoryKernelAgent +from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase +from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings +from semantic_kernel.const import DEFAULT_SERVICE_NAME +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 import KernelServiceNotFoundError +from semantic_kernel.functions.kernel_arguments import KernelArguments +from semantic_kernel.utils.experimental_decorator import experimental_class + +if TYPE_CHECKING: + from semantic_kernel.kernel import Kernel + +logger: logging.Logger = logging.getLogger(__name__) + + +@experimental_class +class ChatCompletionAgent(ChatHistoryKernelAgent): + service_id: str | None = Field(default=DEFAULT_SERVICE_NAME) + execution_settings: PromptExecutionSettings | None = None + + def __init__( + self, + service_id: str | None = None, + name: str | None = None, + id: str | None = None, + description: str | None = None, + instructions: str | None = None, + execution_settings: PromptExecutionSettings | None = None, + ): + """Initialize a new instance of ChatCompletionAgent.""" + super().__init__(name=name, instructions=instructions, id=id, description=description) + self.service_id = service_id + self.execution_settings = execution_settings + + @override + async def invoke(self, kernel: "Kernel", history: ChatHistory) -> AsyncIterable[ChatMessageContent]: # type: ignore + """Invoke the chat history handler.""" + # Get the chat completion service + service = kernel.get_service(service_id=self.service_id, type=ChatCompletionClientBase) + + if not service: + raise KernelServiceNotFoundError(f"Chat completion service not found with service_id: {self.service_id}") + + # Cast the service to the expected type - satisfy type checking + chat_completion_service = cast(ChatCompletionClientBase, service) + + # To satisfy typechecker + if self.service_id is None: + self.service_id = DEFAULT_SERVICE_NAME # pragma: no cover + + settings = ( + self.execution_settings + or kernel.get_prompt_execution_settings_from_service_id(self.service_id) + or chat_completion_service.instantiate_prompt_execution_settings( + service_id=self.service_id, extension_data={"ai_model_id": chat_completion_service.ai_model_id} + ) + ) + + chat = self._setup_agent_chat_history(history) + + message_count = len(chat) + + logger.debug(f"[{type(self).__name__}] Invoking {type(chat_completion_service).__name__}.") + + messages = await chat_completion_service.get_chat_message_contents( + chat_history=chat, + settings=settings, + kernel=kernel, + arguments=KernelArguments(), + ) + + logger.info( + f"[{type(self).__name__}] Invoked {type(chat_completion_service).__name__} " + f"with message count: {message_count}." + ) + + # Capture mutated messages related function calling / tools + for message_index in range(message_count, len(chat)): + message = chat[message_index] + message.name = self.name + history.add_message(message) + + for message in messages: + message.name = self.name + yield message + + @override + async def invoke_streaming( # type: ignore + self, kernel: "Kernel", history: ChatHistory + ) -> AsyncGenerator[StreamingChatMessageContent, None]: + """Invoke the chat history handler in streaming mode.""" + # Get the chat completion service + service = kernel.get_service(service_id=self.service_id, type=ChatCompletionClientBase) + + if not service: + raise KernelServiceNotFoundError(f"Chat completion service not found with service_id: {self.service_id}") + + # Cast the service to the expected type + chat_completion_service = cast(ChatCompletionClientBase, service) + + # To satisfy typechecker + if self.service_id is None: + self.service_id = DEFAULT_SERVICE_NAME # pragma: no cover + + settings = ( + self.execution_settings + or kernel.get_prompt_execution_settings_from_service_id(self.service_id) + or chat_completion_service.instantiate_prompt_execution_settings( + service_id=self.service_id, extension_data={"ai_model_id": chat_completion_service.ai_model_id} + ) + ) + + chat = self._setup_agent_chat_history(history) + + message_count = len(chat) + + logger.debug(f"[{type(self).__name__}] Invoking {type(chat_completion_service).__name__}.") + + messages: AsyncGenerator[list[StreamingChatMessageContent], Any] = ( + chat_completion_service.get_streaming_chat_message_contents( + chat_history=chat, + settings=settings, + kernel=kernel, + arguments=KernelArguments(), + ) + ) + + logger.info( + f"[{type(self).__name__}] Invoked {type(chat_completion_service).__name__} " + f"with message count: {message_count}." + ) + + # Capture mutated messages related function calling / tools + for message_index in range(message_count, len(chat)): + message = chat[message_index] + message.name = self.name + history.add_message(message) + + async for message_list in messages: + for message in message_list: + message.name = self.name + yield message + + def _setup_agent_chat_history(self, history: ChatHistory) -> ChatHistory: + """Setup the agent chat history.""" + chat = [] + + if self.instructions is not None: + chat.append(ChatMessageContent(role=AuthorRole.SYSTEM, content=self.instructions, name=self.name)) + + chat.extend(history.messages if history.messages else []) + + return ChatHistory(messages=chat) diff --git a/python/semantic_kernel/agents/chat_history_channel.py b/python/semantic_kernel/agents/chat_history_channel.py new file mode 100644 index 000000000000..6635a50fed1c --- /dev/null +++ b/python/semantic_kernel/agents/chat_history_channel.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft. All rights reserved. + +from collections.abc import AsyncIterable + +from pydantic import Field + +from semantic_kernel.agents.agent import Agent +from semantic_kernel.agents.agent_channel import AgentChannel +from semantic_kernel.agents.chat_history_handler import ChatHistoryHandler +from semantic_kernel.contents import ChatMessageContent +from semantic_kernel.utils.experimental_decorator import experimental_class + + +@experimental_class +class ChatHistoryChannel(AgentChannel): + history: list[ChatMessageContent] = Field(default_factory=list, alias="history") + + def __init__(self) -> None: + """Initialize the ChatHistoryChannel.""" + super().__init__() + + async def invoke( # type: ignore + self, + agent: Agent, + ) -> AsyncIterable[ChatMessageContent]: + """Perform a discrete incremental interaction between a single Agent and AgentChat.""" + if not isinstance(agent, ChatHistoryHandler): + raise ValueError(f"Invalid channel binding for agent: {agent.id} ({type(agent).__name__})") + + async for message in agent.invoke(self.history): + self.history.append(message) + yield message + + async def receive( + self, + history: list[ChatMessageContent], + ) -> None: + """Receive the conversation messages.""" + self.history.extend(history) + + async def get_history( # type: ignore + self, + ) -> AsyncIterable[ChatMessageContent]: + """Retrieve the message history specific to this channel.""" + for message in reversed(self.history): + yield message diff --git a/python/semantic_kernel/agents/chat_history_handler.py b/python/semantic_kernel/agents/chat_history_handler.py new file mode 100644 index 000000000000..505b0015f5ee --- /dev/null +++ b/python/semantic_kernel/agents/chat_history_handler.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft. All rights reserved. + +from abc import ABC, abstractmethod +from collections.abc import AsyncIterable + +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.kernel_pydantic import KernelBaseModel +from semantic_kernel.utils.experimental_decorator import experimental_class + + +@experimental_class +class ChatHistoryHandler(ABC, KernelBaseModel): + @abstractmethod + async def invoke(self, history: ChatHistory) -> AsyncIterable[ChatMessageContent]: + """Invoke the chat history handler.""" + ... + + @abstractmethod + async def invoke_streaming(self, history: ChatHistory) -> AsyncIterable[StreamingChatMessageContent]: + """Invoke the chat history handler in streaming mode.""" + ... diff --git a/python/semantic_kernel/agents/chat_history_kernel_agent.py b/python/semantic_kernel/agents/chat_history_kernel_agent.py new file mode 100644 index 000000000000..b0c4c66d6e35 --- /dev/null +++ b/python/semantic_kernel/agents/chat_history_kernel_agent.py @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft. All rights reserved. + +import logging +import sys +from abc import ABC, abstractmethod +from collections.abc import AsyncIterable + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override # pragma: no cover + +from semantic_kernel.agents.agent_channel import AgentChannel +from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel +from semantic_kernel.agents.chat_history_handler import ChatHistoryHandler +from semantic_kernel.agents.kernel_agent import KernelAgent +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.utils.experimental_decorator import experimental_class + +logger: logging.Logger = logging.getLogger(__name__) + + +@experimental_class +class ChatHistoryKernelAgent(KernelAgent, ChatHistoryHandler, ABC): + def __init__( + self, + name: str | None = None, + instructions: str | None = None, + id: str | None = None, + description: str | None = None, + ): + """Initialize the ChatHistoryKernelAgent.""" + super().__init__( + name=name, + instructions=instructions, + id=id, + description=description, + ) + + @override + def get_channel_keys(self) -> list[str]: + return [ChatHistoryChannel.__name__] + + @override + def create_channel(self) -> AgentChannel: # type: ignore + return ChatHistoryChannel() + + @abstractmethod + async def invoke(self, history: ChatHistory) -> AsyncIterable[ChatMessageContent]: + """Invoke the chat history handler.""" + ... + + @abstractmethod + async def invoke_streaming(self, history: ChatHistory) -> AsyncIterable[StreamingChatMessageContent]: + """Invoke the chat history handler in streaming mode.""" + ... diff --git a/python/semantic_kernel/agents/kernel_agent.py b/python/semantic_kernel/agents/kernel_agent.py new file mode 100644 index 000000000000..a7e1d6a92153 --- /dev/null +++ b/python/semantic_kernel/agents/kernel_agent.py @@ -0,0 +1,20 @@ +# Copyright (c) Microsoft. All rights reserved. + +from semantic_kernel.agents.agent import Agent +from semantic_kernel.utils.experimental_decorator import experimental_class + + +@experimental_class +class KernelAgent(Agent): + instructions: str | None = None + + def __init__( + self, + name: str | None = None, + instructions: str | None = None, + id: str | None = None, + description: str | None = None, + ): + """Initialize the KernelAgent.""" + super().__init__(name=name, id=id, description=description) + self.instructions = instructions diff --git a/python/tests/samples/test_concepts.py b/python/tests/samples/test_concepts.py index fabc3934d9cd..979410780a13 100644 --- a/python/tests/samples/test_concepts.py +++ b/python/tests/samples/test_concepts.py @@ -2,6 +2,7 @@ from pytest import mark, param +from samples.concepts.agents.step1_agent import main as step1_agent from samples.concepts.auto_function_calling.azure_python_code_interpreter_function_calling import ( main as azure_python_code_interpreter_function_calling, ) @@ -89,6 +90,7 @@ param(custom_service_selector, [], id="custom_service_selector"), param(function_defined_in_json_prompt, ["What is 3+3?", "exit"], id="function_defined_in_json_prompt"), param(function_defined_in_yaml_prompt, ["What is 3+3?", "exit"], id="function_defined_in_yaml_prompt"), + param(step1_agent, [], id="step1_agent"), ] diff --git a/python/tests/unit/agents/test_agent.py b/python/tests/unit/agents/test_agent.py new file mode 100644 index 000000000000..69db658d518d --- /dev/null +++ b/python/tests/unit/agents/test_agent.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft. All rights reserved. + +import uuid +from unittest.mock import AsyncMock + +import pytest + +from semantic_kernel.agents.agent import Agent +from semantic_kernel.agents.agent_channel import AgentChannel + + +# Mock subclass of Agent to implement the abstract methods for testing +class MockAgent(Agent): + def get_channel_keys(self) -> list[str]: + return ["key1", "key2"] + + async def create_channel(self) -> AgentChannel: + return AsyncMock(spec=AgentChannel) + + +@pytest.mark.asyncio +async def test_agent_initialization(): + name = "Test Agent" + description = "A test agent" + id_value = str(uuid.uuid4()) + + agent = MockAgent(name=name, description=description, id=id_value) + + assert agent.name == name + assert agent.description == description + assert agent.id == id_value + + +@pytest.mark.asyncio +async def test_agent_default_id(): + agent = MockAgent() + + assert agent.id is not None + assert isinstance(uuid.UUID(agent.id), uuid.UUID) + + +def test_get_channel_keys(): + agent = MockAgent() + keys = agent.get_channel_keys() + + assert keys == ["key1", "key2"] + + +@pytest.mark.asyncio +async def test_create_channel(): + agent = MockAgent() + channel = await agent.create_channel() + + assert isinstance(channel, AgentChannel) diff --git a/python/tests/unit/agents/test_agent_channel.py b/python/tests/unit/agents/test_agent_channel.py new file mode 100644 index 000000000000..09611088addc --- /dev/null +++ b/python/tests/unit/agents/test_agent_channel.py @@ -0,0 +1,64 @@ +# Copyright (c) Microsoft. All rights reserved. + +from collections.abc import AsyncIterable +from unittest.mock import AsyncMock + +import pytest + +from semantic_kernel.agents.agent import Agent +from semantic_kernel.agents.agent_channel import AgentChannel +from semantic_kernel.contents.chat_message_content import ChatMessageContent +from semantic_kernel.contents.utils.author_role import AuthorRole + + +class MockAgentChannel(AgentChannel): + async def receive(self, history: list[ChatMessageContent]) -> None: + pass + + async def invoke(self, agent: "Agent") -> AsyncIterable[ChatMessageContent]: + yield ChatMessageContent(role=AuthorRole.SYSTEM, content="test message") + + async def get_history(self) -> AsyncIterable[ChatMessageContent]: + yield ChatMessageContent(role=AuthorRole.SYSTEM, content="test history message") + + +@pytest.mark.asyncio +async def test_receive(): + mock_channel = AsyncMock(spec=MockAgentChannel) + + history = [ + ChatMessageContent(role=AuthorRole.SYSTEM, content="test message 1"), + ChatMessageContent(role=AuthorRole.USER, content="test message 2"), + ] + + await mock_channel.receive(history) + mock_channel.receive.assert_called_once_with(history) + + +@pytest.mark.asyncio +async def test_invoke(): + mock_channel = AsyncMock(spec=MockAgentChannel) + agent = AsyncMock() # Assuming Agent is another class you have + + async def async_generator(): + yield ChatMessageContent(role=AuthorRole.SYSTEM, content="test message") + + mock_channel.invoke.return_value = async_generator() + + async for message in mock_channel.invoke(agent): + assert message.content == "test message" + mock_channel.invoke.assert_called_once_with(agent) + + +@pytest.mark.asyncio +async def test_get_history(): + mock_channel = AsyncMock(spec=MockAgentChannel) + + async def async_generator(): + yield ChatMessageContent(role=AuthorRole.SYSTEM, content="test history message") + + mock_channel.get_history.return_value = async_generator() + + async for message in mock_channel.get_history(): + assert message.content == "test history message" + mock_channel.get_history.assert_called_once() diff --git a/python/tests/unit/agents/test_chat_completion_agent.py b/python/tests/unit/agents/test_chat_completion_agent.py new file mode 100644 index 000000000000..bd55e80b0a3f --- /dev/null +++ b/python/tests/unit/agents/test_chat_completion_agent.py @@ -0,0 +1,159 @@ +# Copyright (c) Microsoft. All rights reserved. + +from unittest.mock import AsyncMock, create_autospec + +import pytest + +from semantic_kernel.agents.chat_completion_agent import ChatCompletionAgent +from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel +from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase +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 import KernelServiceNotFoundError +from semantic_kernel.kernel import Kernel + + +@pytest.mark.asyncio +async def test_initialization(): + agent = ChatCompletionAgent( + service_id="test_service", + name="Test Agent", + id="test_id", + description="Test Description", + instructions="Test Instructions", + ) + + assert agent.service_id == "test_service" + assert agent.name == "Test Agent" + assert agent.id == "test_id" + assert agent.description == "Test Description" + assert agent.instructions == "Test Instructions" + + +@pytest.mark.asyncio +async def test_invoke(): + agent = ChatCompletionAgent(service_id="test_service", name="Test Agent", instructions="Test Instructions") + + kernel = create_autospec(Kernel) + kernel.get_service.return_value = create_autospec(ChatCompletionClientBase) + kernel.get_service.return_value.get_chat_message_contents = AsyncMock( + return_value=[ChatMessageContent(role=AuthorRole.SYSTEM, content="Processed Message")] + ) + + history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) + + messages = [message async for message in agent.invoke(kernel, history)] + + assert len(messages) == 1 + assert messages[0].content == "Processed Message" + + +@pytest.mark.asyncio +async def test_invoke_tool_call_added(): + agent = ChatCompletionAgent(service_id="test_service", name="Test Agent") + + kernel = create_autospec(Kernel) + chat_completion_service = create_autospec(ChatCompletionClientBase) + kernel.get_service.return_value = chat_completion_service + + # Define the initial chat history + history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) + + # Modify the mock to add new messages to the chat history + async def mock_get_chat_message_contents(chat_history, settings, kernel, arguments): + # Simulate adding new messages to the chat history + new_messages = [ + ChatMessageContent(role=AuthorRole.SYSTEM, content="Processed Message 1"), + ChatMessageContent(role=AuthorRole.TOOL, content="Processed Message 2"), + ] + chat_history.messages.extend(new_messages) + return new_messages + + chat_completion_service.get_chat_message_contents = AsyncMock(side_effect=mock_get_chat_message_contents) + + messages = [message async for message in agent.invoke(kernel, history)] + + assert len(messages) == 2 + assert messages[0].content == "Processed Message 1" + assert messages[1].content == "Processed Message 2" + + # Check that the new messages were added to history + assert len(history.messages) == 3 # Initial message + 2 new messages + assert history.messages[1].content == "Processed Message 1" + assert history.messages[2].content == "Processed Message 2" + assert history.messages[1].name == "Test Agent" + assert history.messages[2].name == "Test Agent" + + +@pytest.mark.asyncio +async def test_invoke_no_service_throws(): + agent = ChatCompletionAgent(service_id="test_service", name="Test Agent") + + kernel = create_autospec(Kernel) + kernel.get_service.return_value = None + + history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) + + with pytest.raises(KernelServiceNotFoundError): + async for _ in agent.invoke(kernel, history): + pass + + +@pytest.mark.asyncio +async def test_invoke_streaming(): + agent = ChatCompletionAgent(service_id="test_service", name="Test Agent") + + kernel = create_autospec(Kernel) + chat_completion_service = create_autospec(ChatCompletionClientBase) + kernel.get_service.return_value = chat_completion_service + + # Define the initial chat history + history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) + + # Modify the mock to add new messages to the chat history + async def mock_get_streaming_chat_message_contents(chat_history, settings, kernel, arguments): + # Simulate adding new messages to the chat history + new_messages = [ + ChatMessageContent(role=AuthorRole.SYSTEM, content="Processed Message 1"), + ChatMessageContent(role=AuthorRole.TOOL, content="Processed Message 2"), + ] + chat_history.messages.extend(new_messages) + for message in new_messages: + yield message + + chat_completion_service.get_streaming_chat_message_contents = mock_get_streaming_chat_message_contents + + messages = [message async for message in agent.invoke_streaming(kernel, history)] + + assert len(messages) == 2 + assert messages[0].content == "Processed Message 1" + assert messages[1].content == "Processed Message 2" + + +@pytest.mark.asyncio +async def test_invoke_streaming_no_service_throws(): + agent = ChatCompletionAgent(service_id="test_service", name="Test Agent") + + kernel = create_autospec(Kernel) + kernel.get_service.return_value = None + + history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) + + with pytest.raises(KernelServiceNotFoundError): + async for _ in agent.invoke_streaming(kernel, history): + pass + + +def test_get_channel_keys(): + agent = ChatCompletionAgent() + keys = agent.get_channel_keys() + + assert keys == [ChatHistoryChannel.__name__] + + +def test_create_channel(): + agent = ChatCompletionAgent() + channel = agent.create_channel() + + assert isinstance(channel, ChatHistoryChannel) diff --git a/python/tests/unit/agents/test_chat_history_channel.py b/python/tests/unit/agents/test_chat_history_channel.py new file mode 100644 index 000000000000..6164217ce077 --- /dev/null +++ b/python/tests/unit/agents/test_chat_history_channel.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft. All rights reserved. + +from collections.abc import AsyncIterable + +import pytest + +from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel +from semantic_kernel.agents.chat_history_handler import ChatHistoryHandler +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 + + +class MockChatHistoryHandler(ChatHistoryHandler): + """Mock agent to test chat history handling""" + + async def invoke(self, history: list[ChatMessageContent]) -> AsyncIterable[ChatMessageContent]: + for message in history: + yield ChatMessageContent(role=AuthorRole.SYSTEM, content=f"Processed: {message.content}") + + async def invoke_streaming(self, history: list[ChatMessageContent]) -> AsyncIterable["StreamingChatMessageContent"]: + pass + + +class MockNonChatHistoryHandler: + """Mock agent to test incorrect instance handling.""" + + id: str = "mock_non_chat_history_handler" + + +@pytest.mark.asyncio +async def test_invoke(): + channel = ChatHistoryChannel() + agent = MockChatHistoryHandler() + + initial_message = ChatMessageContent(role=AuthorRole.USER, content="Initial message") + channel.history.append(initial_message) + + received_messages = [] + async for message in channel.invoke(agent): + received_messages.append(message) + break # only process one message for the test + + assert len(received_messages) == 1 + assert "Processed: Initial message" in received_messages[0].content + + +@pytest.mark.asyncio +async def test_invoke_incorrect_instance_throws(): + channel = ChatHistoryChannel() + agent = MockNonChatHistoryHandler() + + with pytest.raises(ValueError): + async for _ in channel.invoke(agent): + pass + + +@pytest.mark.asyncio +async def test_receive(): + channel = ChatHistoryChannel() + history = [ + ChatMessageContent(role=AuthorRole.SYSTEM, content="test message 1"), + ChatMessageContent(role=AuthorRole.USER, content="test message 2"), + ] + + await channel.receive(history) + + assert len(channel.history) == 2 + assert channel.history[0].content == "test message 1" + assert channel.history[0].role == AuthorRole.SYSTEM + assert channel.history[1].content == "test message 2" + assert channel.history[1].role == AuthorRole.USER + + +@pytest.mark.asyncio +async def test_get_history(): + channel = ChatHistoryChannel() + history = [ + ChatMessageContent(role=AuthorRole.SYSTEM, content="test message 1"), + ChatMessageContent(role=AuthorRole.USER, content="test message 2"), + ] + channel.history.extend(history) + + messages = [message async for message in channel.get_history()] + + assert len(messages) == 2 + assert messages[0].content == "test message 2" + assert messages[0].role == AuthorRole.USER + assert messages[1].content == "test message 1" + assert messages[1].role == AuthorRole.SYSTEM diff --git a/python/tests/unit/agents/test_chat_history_kernel_agent.py b/python/tests/unit/agents/test_chat_history_kernel_agent.py new file mode 100644 index 000000000000..68594547ed1e --- /dev/null +++ b/python/tests/unit/agents/test_chat_history_kernel_agent.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft. All rights reserved. + +import uuid +from collections.abc import AsyncIterable + +from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel +from semantic_kernel.agents.chat_history_kernel_agent import ChatHistoryKernelAgent +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 + + +# Define the mock subclass for ChatHistoryKernelAgent +class MockChatHistoryKernelAgent(ChatHistoryKernelAgent): + async def invoke(self, history: list[ChatMessageContent]) -> AsyncIterable[ChatMessageContent]: + for message in history: + yield ChatMessageContent(role=AuthorRole.SYSTEM, content=f"Processed: {message.content}") + + async def invoke_streaming(self, history: list[ChatMessageContent]) -> AsyncIterable[StreamingChatMessageContent]: + pass + + +def test_initialization(): + name = "Test Agent" + instructions = "These are the instructions" + id_value = str(uuid.uuid4()) + description = "This is a test agent" + + agent = MockChatHistoryKernelAgent(name=name, instructions=instructions, id=id_value, description=description) + + assert agent.name == name + assert agent.instructions == instructions + assert agent.id == id_value + assert agent.description == description + + +def test_default_id(): + agent = MockChatHistoryKernelAgent() + + assert agent.id is not None + assert isinstance(uuid.UUID(agent.id), uuid.UUID) + + +def test_get_channel_keys(): + agent = MockChatHistoryKernelAgent() + keys = agent.get_channel_keys() + + assert keys == [ChatHistoryChannel.__name__] + + +def test_create_channel(): + agent = MockChatHistoryKernelAgent() + channel = agent.create_channel() + + assert isinstance(channel, ChatHistoryChannel) From 135e0751e1278fcdf962e8d5a2fe4d678eebfc2b Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 5 Jul 2024 13:14:37 -0400 Subject: [PATCH 02/17] Clean up comments --- python/tests/unit/agents/test_agent.py | 1 - python/tests/unit/agents/test_agent_channel.py | 2 +- python/tests/unit/agents/test_chat_completion_agent.py | 9 +-------- .../tests/unit/agents/test_chat_history_kernel_agent.py | 1 - 4 files changed, 2 insertions(+), 11 deletions(-) diff --git a/python/tests/unit/agents/test_agent.py b/python/tests/unit/agents/test_agent.py index 69db658d518d..93f5986f7ea4 100644 --- a/python/tests/unit/agents/test_agent.py +++ b/python/tests/unit/agents/test_agent.py @@ -9,7 +9,6 @@ from semantic_kernel.agents.agent_channel import AgentChannel -# Mock subclass of Agent to implement the abstract methods for testing class MockAgent(Agent): def get_channel_keys(self) -> list[str]: return ["key1", "key2"] diff --git a/python/tests/unit/agents/test_agent_channel.py b/python/tests/unit/agents/test_agent_channel.py index 09611088addc..20b61d956686 100644 --- a/python/tests/unit/agents/test_agent_channel.py +++ b/python/tests/unit/agents/test_agent_channel.py @@ -38,7 +38,7 @@ async def test_receive(): @pytest.mark.asyncio async def test_invoke(): mock_channel = AsyncMock(spec=MockAgentChannel) - agent = AsyncMock() # Assuming Agent is another class you have + agent = AsyncMock() async def async_generator(): yield ChatMessageContent(role=AuthorRole.SYSTEM, content="test message") diff --git a/python/tests/unit/agents/test_chat_completion_agent.py b/python/tests/unit/agents/test_chat_completion_agent.py index bd55e80b0a3f..5115b6669b88 100644 --- a/python/tests/unit/agents/test_chat_completion_agent.py +++ b/python/tests/unit/agents/test_chat_completion_agent.py @@ -57,12 +57,9 @@ async def test_invoke_tool_call_added(): chat_completion_service = create_autospec(ChatCompletionClientBase) kernel.get_service.return_value = chat_completion_service - # Define the initial chat history history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) - # Modify the mock to add new messages to the chat history async def mock_get_chat_message_contents(chat_history, settings, kernel, arguments): - # Simulate adding new messages to the chat history new_messages = [ ChatMessageContent(role=AuthorRole.SYSTEM, content="Processed Message 1"), ChatMessageContent(role=AuthorRole.TOOL, content="Processed Message 2"), @@ -78,8 +75,7 @@ async def mock_get_chat_message_contents(chat_history, settings, kernel, argumen assert messages[0].content == "Processed Message 1" assert messages[1].content == "Processed Message 2" - # Check that the new messages were added to history - assert len(history.messages) == 3 # Initial message + 2 new messages + assert len(history.messages) == 3 assert history.messages[1].content == "Processed Message 1" assert history.messages[2].content == "Processed Message 2" assert history.messages[1].name == "Test Agent" @@ -108,12 +104,9 @@ async def test_invoke_streaming(): chat_completion_service = create_autospec(ChatCompletionClientBase) kernel.get_service.return_value = chat_completion_service - # Define the initial chat history history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) - # Modify the mock to add new messages to the chat history async def mock_get_streaming_chat_message_contents(chat_history, settings, kernel, arguments): - # Simulate adding new messages to the chat history new_messages = [ ChatMessageContent(role=AuthorRole.SYSTEM, content="Processed Message 1"), ChatMessageContent(role=AuthorRole.TOOL, content="Processed Message 2"), diff --git a/python/tests/unit/agents/test_chat_history_kernel_agent.py b/python/tests/unit/agents/test_chat_history_kernel_agent.py index 68594547ed1e..ab933b6e83a8 100644 --- a/python/tests/unit/agents/test_chat_history_kernel_agent.py +++ b/python/tests/unit/agents/test_chat_history_kernel_agent.py @@ -10,7 +10,6 @@ from semantic_kernel.contents.utils.author_role import AuthorRole -# Define the mock subclass for ChatHistoryKernelAgent class MockChatHistoryKernelAgent(ChatHistoryKernelAgent): async def invoke(self, history: list[ChatMessageContent]) -> AsyncIterable[ChatMessageContent]: for message in history: From 403488f880b513605cb374db275de05cd9f1de4e Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 5 Jul 2024 15:11:04 -0400 Subject: [PATCH 03/17] Improve docstrings. Add step2 concept sample. --- python/samples/concepts/agents/README.md | 1 + python/samples/concepts/agents/step1_agent.py | 2 +- .../samples/concepts/agents/step2_plugins.py | 85 +++++++++++++++++++ python/semantic_kernel/agents/agent.py | 29 ++++++- .../semantic_kernel/agents/agent_channel.py | 28 +++++- .../agents/chat_completion_agent.py | 42 ++++++++- .../agents/chat_history_channel.py | 23 ++++- .../agents/chat_history_handler.py | 12 ++- .../agents/chat_history_kernel_agent.py | 41 ++++++++- python/semantic_kernel/agents/kernel_agent.py | 13 ++- python/tests/samples/test_concepts.py | 2 + 11 files changed, 256 insertions(+), 22 deletions(-) create mode 100644 python/samples/concepts/agents/step2_plugins.py diff --git a/python/samples/concepts/agents/README.md b/python/samples/concepts/agents/README.md index 82d1a6bebb49..70b55138950a 100644 --- a/python/samples/concepts/agents/README.md +++ b/python/samples/concepts/agents/README.md @@ -16,6 +16,7 @@ The getting started with agents examples include: Example|Description ---|--- [step1_agent](../agents/step1_agent.py)|How to create and use an agent. +[step2_plugins](../agents/step2_plugins.py)|How to associate plugins with an agent. ## Configuring the Kernel diff --git a/python/samples/concepts/agents/step1_agent.py b/python/samples/concepts/agents/step1_agent.py index 49835512d384..9ef0d1d4176b 100644 --- a/python/samples/concepts/agents/step1_agent.py +++ b/python/samples/concepts/agents/step1_agent.py @@ -15,7 +15,7 @@ ################################################################### # To toggle streaming or non-streaming mode, change the following boolean -streaming = False +streaming = True # Define the agent name and instructions PARROT_NAME = "Parrot" diff --git a/python/samples/concepts/agents/step2_plugins.py b/python/samples/concepts/agents/step2_plugins.py new file mode 100644 index 000000000000..b3381229991a --- /dev/null +++ b/python/samples/concepts/agents/step2_plugins.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import Annotated + +from semantic_kernel.agents.chat_completion_agent import ChatCompletionAgent +from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior +from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion +from semantic_kernel.contents.chat_history import ChatHistory +from semantic_kernel.contents.utils.author_role import AuthorRole +from semantic_kernel.functions.kernel_function_decorator import kernel_function +from semantic_kernel.kernel import Kernel + +################################################################### +# The following sample demonstrates how to create a simple, # +# non-group agent that utilizes plugins defined as part of # +# the Kernel. # +################################################################### + +# Define the agent name and instructions +HOST_NAME = "Host" +HOST_INSTRUCTIONS = "Answer questions about the menu." + + +# Define a sample plugin for the sample +class MenuPlugin: + """A sample Menu Plugin used for the concept sample.""" + + @kernel_function(description="Provides a list of specials from the menu.") + def get_specials(self) -> Annotated[str, "Returns the specials from the menu."]: + return """ + Special Soup: Clam Chowder + Special Salad: Cobb Salad + Special Drink: Chai Tea + """ + + @kernel_function(description="Provides the price of the requested menu item.") + def get_item_price( + self, menu_item: Annotated[str, "The name of the menu item."] + ) -> Annotated[str, "Returns the price of the menu item."]: + return "$9.99" + + +# A helper method to invoke the agent with the user input +async def invoke_agent(agent: ChatCompletionAgent, input: str, kernel: Kernel, chat: ChatHistory) -> None: + """Invoke the agent with the user input.""" + chat.add_user_message(input) + + print(f"# {AuthorRole.USER}: '{input}'") + + async for content in agent.invoke(kernel, chat): + print(f"# {content.role} - {content.name or '*'}: '{content.content}'") + + +async def main(): + # Create the instance of the Kernel + kernel = Kernel() + + # Add the OpenAIChatCompletion AI Service to the Kernel + service_id = "agent" + kernel.add_service(AzureChatCompletion(service_id=service_id)) + + settings = kernel.get_prompt_execution_settings_from_service_id(service_id=service_id) + # Configure the function choice behavior to auto invoke kernel functions + settings.function_choice_behavior = FunctionChoiceBehavior.Auto() + + # Create the agent + agent = ChatCompletionAgent( + service_id="agent", name=HOST_NAME, instructions=HOST_INSTRUCTIONS, execution_settings=settings + ) + + kernel.add_plugin(plugin=MenuPlugin(), plugin_name="menu") + + # Define the chat history + chat = ChatHistory() + + # Respond to user input + await invoke_agent(agent, "Hello", kernel, chat) + await invoke_agent(agent, "What is the special soup?", kernel, chat) + await invoke_agent(agent, "What is the special drink?", kernel, chat) + await invoke_agent(agent, "Thank you", kernel, chat) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/semantic_kernel/agents/agent.py b/python/semantic_kernel/agents/agent.py index c4da51b2f865..07bdf2cec5a0 100644 --- a/python/semantic_kernel/agents/agent.py +++ b/python/semantic_kernel/agents/agent.py @@ -10,22 +10,45 @@ @experimental_class class Agent(ABC, KernelBaseModel): + """Base abstraction for all Semantic Kernel agents. + + An agent instance may participate in one or more conversations. + A conversation may include one or more agents. + In addition to identity and descriptive meta-data, an Agent + must define its communication protocol, or AgentChannel. + """ + id: str description: str | None = None name: str | None = None def __init__(self, name: str | None = None, description: str | None = None, id: str | None = None): - """Initialize the Agent.""" + """Initialize the Agent. + + Args: + name: The name of the agent (optional). + description: The description of the agent (optional). + id: The unique identifier of the agent (optional). If no id is provided, + a new UUID will be generated. + """ if id is None: id = str(uuid.uuid4()) super().__init__(id=id, description=description, name=name) @abstractmethod def get_channel_keys(self) -> list[str]: - """Get the channel keys.""" + """Get the channel keys. + + Returns: + A list of channel keys. + """ ... @abstractmethod async def create_channel(self) -> AgentChannel: - """Create a channel.""" + """Create a channel. + + Returns: + An instance of AgentChannel. + """ ... diff --git a/python/semantic_kernel/agents/agent_channel.py b/python/semantic_kernel/agents/agent_channel.py index 4f90a24f4350..fbf6a4e14ba4 100644 --- a/python/semantic_kernel/agents/agent_channel.py +++ b/python/semantic_kernel/agents/agent_channel.py @@ -15,12 +15,23 @@ @experimental_class class AgentChannel(ABC, BaseModel): + """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.""" + """Receive the conversation messages. + + Used when joining a conversation and also during each agent interaction. + + Args: + history: The history of messages in the conversation. + """ pass @abstractmethod @@ -28,12 +39,23 @@ async def invoke( self, agent: "Agent", ) -> AsyncIterable[ChatMessageContent]: - """Perform a discrete incremental interaction between a single Agent and AgentChat.""" + """Perform a discrete incremental interaction between a single Agent and AgentChat. + + Args: + agent: The agent to interact with. + + Returns: + An async iterable of ChatMessageContent. + """ pass @abstractmethod async def get_history( self, ) -> AsyncIterable[ChatMessageContent]: - """Retrieve the message history specific to this channel.""" + """Retrieve the message history specific to this channel. + + Returns: + An async iterable of ChatMessageContent. + """ pass diff --git a/python/semantic_kernel/agents/chat_completion_agent.py b/python/semantic_kernel/agents/chat_completion_agent.py index 08cb7a044db4..d320a219f332 100644 --- a/python/semantic_kernel/agents/chat_completion_agent.py +++ b/python/semantic_kernel/agents/chat_completion_agent.py @@ -32,6 +32,13 @@ @experimental_class class ChatCompletionAgent(ChatHistoryKernelAgent): + """A KernelAgent specialization based on ChatCompletionClientBase. + + Note: enable `function_choice_behavior` on the PromptExecutionSettings to enable function + choice behavior which allows the kernel to utilize plugins and functions registered in + the kernel. + """ + service_id: str | None = Field(default=DEFAULT_SERVICE_NAME) execution_settings: PromptExecutionSettings | None = None @@ -43,15 +50,34 @@ def __init__( description: str | None = None, instructions: str | None = None, execution_settings: PromptExecutionSettings | None = None, - ): - """Initialize a new instance of ChatCompletionAgent.""" + ) -> None: + """Initialize a new instance of ChatCompletionAgent. + + Args: + service_id: The service id for the chat completion service. (optional) If not provided, + the default service name `default` will be used. + name: The name of the agent. (optional) + id: The unique identifier for the agent. (optional) If not provided, + a unique GUID will be generated. + description: The description of the agent. (optional) + instructions: The instructions for the agent. (optional) + execution_settings: The execution settings for the agent. (optional) + """ super().__init__(name=name, instructions=instructions, id=id, description=description) self.service_id = service_id self.execution_settings = execution_settings @override async def invoke(self, kernel: "Kernel", history: ChatHistory) -> AsyncIterable[ChatMessageContent]: # type: ignore - """Invoke the chat history handler.""" + """Invoke the chat history handler. + + Args: + kernel: The kernel instance. + history: The chat history. + + Returns: + An async iterable of ChatMessageContent. + """ # Get the chat completion service service = kernel.get_service(service_id=self.service_id, type=ChatCompletionClientBase) @@ -105,7 +131,15 @@ async def invoke(self, kernel: "Kernel", history: ChatHistory) -> AsyncIterable[ async def invoke_streaming( # type: ignore self, kernel: "Kernel", history: ChatHistory ) -> AsyncGenerator[StreamingChatMessageContent, None]: - """Invoke the chat history handler in streaming mode.""" + """Invoke the chat history handler in streaming mode. + + Args: + kernel: The kernel instance. + history: The chat history. + + Returns: + An async generator of StreamingChatMessageContent. + """ # Get the chat completion service service = kernel.get_service(service_id=self.service_id, type=ChatCompletionClientBase) diff --git a/python/semantic_kernel/agents/chat_history_channel.py b/python/semantic_kernel/agents/chat_history_channel.py index 6635a50fed1c..c1e379ef4365 100644 --- a/python/semantic_kernel/agents/chat_history_channel.py +++ b/python/semantic_kernel/agents/chat_history_channel.py @@ -13,6 +13,8 @@ @experimental_class class ChatHistoryChannel(AgentChannel): + """An AgentChannel specialization for that acts upon a ChatHistoryHandler.""" + history: list[ChatMessageContent] = Field(default_factory=list, alias="history") def __init__(self) -> None: @@ -23,7 +25,14 @@ async def invoke( # type: ignore self, agent: Agent, ) -> AsyncIterable[ChatMessageContent]: - """Perform a discrete incremental interaction between a single Agent and AgentChat.""" + """Perform a discrete incremental interaction between a single Agent and AgentChat. + + Args: + agent: The agent to interact with. + + Returns: + An async iterable of ChatMessageContent. + """ if not isinstance(agent, ChatHistoryHandler): raise ValueError(f"Invalid channel binding for agent: {agent.id} ({type(agent).__name__})") @@ -35,12 +44,20 @@ async def receive( self, history: list[ChatMessageContent], ) -> None: - """Receive the conversation messages.""" + """Receive the conversation messages. + + Args: + history: The history of messages in the conversation. + """ self.history.extend(history) async def get_history( # type: ignore self, ) -> AsyncIterable[ChatMessageContent]: - """Retrieve the message history specific to this channel.""" + """Retrieve the message history specific to this channel. + + Returns: + An async iterable of ChatMessageContent. + """ for message in reversed(self.history): yield message diff --git a/python/semantic_kernel/agents/chat_history_handler.py b/python/semantic_kernel/agents/chat_history_handler.py index 505b0015f5ee..f36952adc4b8 100644 --- a/python/semantic_kernel/agents/chat_history_handler.py +++ b/python/semantic_kernel/agents/chat_history_handler.py @@ -12,12 +12,20 @@ @experimental_class class ChatHistoryHandler(ABC, KernelBaseModel): + """Contract for an agent that utilizes a ChatHistoryChannel.""" + @abstractmethod async def invoke(self, history: ChatHistory) -> AsyncIterable[ChatMessageContent]: - """Invoke the chat history handler.""" + """Invoke the chat history handler. + + Entry point for calling into an agent from a ChatHistoryChannel + """ ... @abstractmethod async def invoke_streaming(self, history: ChatHistory) -> AsyncIterable[StreamingChatMessageContent]: - """Invoke the chat history handler in streaming mode.""" + """Invoke the chat history handler in streaming mode. + + Entry point for calling into an agent from a ChatHistoryChannel for streaming content. + """ ... diff --git a/python/semantic_kernel/agents/chat_history_kernel_agent.py b/python/semantic_kernel/agents/chat_history_kernel_agent.py index b0c4c66d6e35..e165e65c88f9 100644 --- a/python/semantic_kernel/agents/chat_history_kernel_agent.py +++ b/python/semantic_kernel/agents/chat_history_kernel_agent.py @@ -24,14 +24,23 @@ @experimental_class class ChatHistoryKernelAgent(KernelAgent, ChatHistoryHandler, ABC): + """A KernelAgent specialization bound to a ChatHistoryChannel.""" + def __init__( self, name: str | None = None, instructions: str | None = None, id: str | None = None, description: str | None = None, - ): - """Initialize the ChatHistoryKernelAgent.""" + ) -> None: + """Initialize the ChatHistoryKernelAgent. + + Args: + name: The name of the agent. + instructions: The instructions for the agent. + id: The unique identifier for the agent. + description: The description of the agent. + """ super().__init__( name=name, instructions=instructions, @@ -41,18 +50,42 @@ def __init__( @override def get_channel_keys(self) -> list[str]: + """Get the channel keys. + + Returns: + A list of channel keys. + """ return [ChatHistoryChannel.__name__] @override def create_channel(self) -> AgentChannel: # type: ignore + """Create a channel. + + Returns: + An instance of AgentChannel. + """ return ChatHistoryChannel() @abstractmethod async def invoke(self, history: ChatHistory) -> AsyncIterable[ChatMessageContent]: - """Invoke the chat history handler.""" + """Invoke the chat history handler. + + Args: + history: The chat history. + + Returns: + An async iterable of ChatMessageContent. + """ ... @abstractmethod async def invoke_streaming(self, history: ChatHistory) -> AsyncIterable[StreamingChatMessageContent]: - """Invoke the chat history handler in streaming mode.""" + """Invoke the chat history handler in streaming mode. + + Args: + history: The chat history. + + Returns: + An async iterable of StreamingChatMessageContent. + """ ... diff --git a/python/semantic_kernel/agents/kernel_agent.py b/python/semantic_kernel/agents/kernel_agent.py index a7e1d6a92153..5f24254f26e2 100644 --- a/python/semantic_kernel/agents/kernel_agent.py +++ b/python/semantic_kernel/agents/kernel_agent.py @@ -6,6 +6,8 @@ @experimental_class class KernelAgent(Agent): + """Base class for agents utilizing Kernel plugins or services.""" + instructions: str | None = None def __init__( @@ -14,7 +16,14 @@ def __init__( instructions: str | None = None, id: str | None = None, description: str | None = None, - ): - """Initialize the KernelAgent.""" + ) -> None: + """Initialize the KernelAgent. + + Args: + name: The name of the agent. + instructions: The instructions for the agent. + id: The unique identifier for the agent. + description: The description of the agent. + """ super().__init__(name=name, id=id, description=description) self.instructions = instructions diff --git a/python/tests/samples/test_concepts.py b/python/tests/samples/test_concepts.py index 979410780a13..4af67af2652c 100644 --- a/python/tests/samples/test_concepts.py +++ b/python/tests/samples/test_concepts.py @@ -3,6 +3,7 @@ from pytest import mark, param from samples.concepts.agents.step1_agent import main as step1_agent +from samples.concepts.agents.step2_plugins import main as step2_plugins from samples.concepts.auto_function_calling.azure_python_code_interpreter_function_calling import ( main as azure_python_code_interpreter_function_calling, ) @@ -91,6 +92,7 @@ param(function_defined_in_json_prompt, ["What is 3+3?", "exit"], id="function_defined_in_json_prompt"), param(function_defined_in_yaml_prompt, ["What is 3+3?", "exit"], id="function_defined_in_yaml_prompt"), param(step1_agent, [], id="step1_agent"), + param(step2_plugins, [], id="step2_agent_plugins"), ] From 397b484476dec153899d3c4e46b3d1af337a94b2 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 5 Jul 2024 15:41:16 -0400 Subject: [PATCH 04/17] Fix streaming unit test. --- .../unit/agents/test_chat_completion_agent.py | 27 +++++++------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/python/tests/unit/agents/test_chat_completion_agent.py b/python/tests/unit/agents/test_chat_completion_agent.py index 5115b6669b88..848e2267b5e6 100644 --- a/python/tests/unit/agents/test_chat_completion_agent.py +++ b/python/tests/unit/agents/test_chat_completion_agent.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. -from unittest.mock import AsyncMock, create_autospec +from unittest.mock import AsyncMock, create_autospec, patch import pytest @@ -101,27 +101,18 @@ async def test_invoke_streaming(): agent = ChatCompletionAgent(service_id="test_service", name="Test Agent") kernel = create_autospec(Kernel) - chat_completion_service = create_autospec(ChatCompletionClientBase) - kernel.get_service.return_value = chat_completion_service history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) - async def mock_get_streaming_chat_message_contents(chat_history, settings, kernel, arguments): - new_messages = [ - ChatMessageContent(role=AuthorRole.SYSTEM, content="Processed Message 1"), - ChatMessageContent(role=AuthorRole.TOOL, content="Processed Message 2"), - ] - chat_history.messages.extend(new_messages) - for message in new_messages: - yield message - - chat_completion_service.get_streaming_chat_message_contents = mock_get_streaming_chat_message_contents - - messages = [message async for message in agent.invoke_streaming(kernel, history)] + with patch( + "semantic_kernel.connectors.ai.chat_completion_client_base.ChatCompletionClientBase.get_streaming_chat_message_contents", + return_value=AsyncMock(), + ) as mock: + mock.return_value.__aiter__.return_value = [ChatMessageContent(role=AuthorRole.USER, content="Initial Message")] - assert len(messages) == 2 - assert messages[0].content == "Processed Message 1" - assert messages[1].content == "Processed Message 2" + async for message in agent.invoke_streaming(kernel, history): + assert message.role == AuthorRole.USER + assert message.content == "Initial Message" @pytest.mark.asyncio From 44f1fd08a65c6a57c3bd0693de6179cf2f1938ea Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Tue, 9 Jul 2024 20:10:58 -0400 Subject: [PATCH 05/17] PR feedback --- python/samples/concepts/agents/step1_agent.py | 2 +- python/semantic_kernel/agents/__init__.py | 4 +-- .../agents/{agent.py => agent_base.py} | 8 +++-- .../semantic_kernel/agents/agent_channel.py | 8 ++--- .../agents/chat_completion_agent.py | 35 +++++++------------ .../agents/chat_history_channel.py | 15 ++++++-- .../agents/chat_history_handler.py | 2 +- .../agents/chat_history_kernel_agent.py | 5 ++- python/semantic_kernel/agents/kernel_agent.py | 8 +++-- python/tests/unit/agents/test_agent.py | 16 +++++++-- .../tests/unit/agents/test_agent_channel.py | 4 +-- .../unit/agents/test_chat_completion_agent.py | 21 +++++++++-- .../unit/agents/test_chat_history_channel.py | 2 +- .../agents/test_chat_history_kernel_agent.py | 12 ++++++- 14 files changed, 92 insertions(+), 50 deletions(-) rename python/semantic_kernel/agents/{agent.py => agent_base.py} (81%) diff --git a/python/samples/concepts/agents/step1_agent.py b/python/samples/concepts/agents/step1_agent.py index 9ef0d1d4176b..e329e73b0ff7 100644 --- a/python/samples/concepts/agents/step1_agent.py +++ b/python/samples/concepts/agents/step1_agent.py @@ -31,7 +31,7 @@ async def invoke_agent(agent: ChatCompletionAgent, input: str, kernel: Kernel, c if streaming: contents = [] content_name = "" - async for content in agent.invoke_streaming(kernel, chat): + async for content in agent.invoke_stream(kernel, chat): content_name = content.name contents.append(content) print(f"# {content.role} - {content_name or '*'}: '{''.join([content.content for content in contents])}'") diff --git a/python/semantic_kernel/agents/__init__.py b/python/semantic_kernel/agents/__init__.py index 9d1a745eda4d..5e1af76a1ea9 100644 --- a/python/semantic_kernel/agents/__init__.py +++ b/python/semantic_kernel/agents/__init__.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. -from semantic_kernel.agents.agent import Agent +from semantic_kernel.agents.agent_base import AgentBase from semantic_kernel.agents.agent_channel import AgentChannel from semantic_kernel.agents.chat_completion_agent import ChatCompletionAgent from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel @@ -9,7 +9,7 @@ from semantic_kernel.agents.kernel_agent import KernelAgent __all__ = [ - "Agent", + "AgentBase", "AgentChannel", "ChatCompletionAgent", "ChatHistoryChannel", diff --git a/python/semantic_kernel/agents/agent.py b/python/semantic_kernel/agents/agent_base.py similarity index 81% rename from python/semantic_kernel/agents/agent.py rename to python/semantic_kernel/agents/agent_base.py index 07bdf2cec5a0..a9c5edc9f198 100644 --- a/python/semantic_kernel/agents/agent.py +++ b/python/semantic_kernel/agents/agent_base.py @@ -9,7 +9,7 @@ @experimental_class -class Agent(ABC, KernelBaseModel): +class AgentBase(ABC, KernelBaseModel): """Base abstraction for all Semantic Kernel agents. An agent instance may participate in one or more conversations. @@ -18,14 +18,16 @@ class Agent(ABC, KernelBaseModel): must define its communication protocol, or AgentChannel. """ + service_id: str id: str description: str | None = None name: str | None = None - def __init__(self, name: str | None = None, description: str | None = None, id: str | None = None): + def __init__(self, service_id: str, name: str | None = None, description: str | None = None, id: str | None = None): """Initialize the Agent. Args: + service_id: The unique identifier of the service. name: The name of the agent (optional). description: The description of the agent (optional). id: The unique identifier of the agent (optional). If no id is provided, @@ -33,7 +35,7 @@ def __init__(self, name: str | None = None, description: str | None = None, id: """ if id is None: id = str(uuid.uuid4()) - super().__init__(id=id, description=description, name=name) + super().__init__(service_id=service_id, id=id, description=description, name=name) @abstractmethod def get_channel_keys(self) -> list[str]: diff --git a/python/semantic_kernel/agents/agent_channel.py b/python/semantic_kernel/agents/agent_channel.py index fbf6a4e14ba4..bffa95280bc4 100644 --- a/python/semantic_kernel/agents/agent_channel.py +++ b/python/semantic_kernel/agents/agent_channel.py @@ -10,7 +10,7 @@ from semantic_kernel.utils.experimental_decorator import experimental_class if TYPE_CHECKING: - from semantic_kernel.agents.agent import Agent + from semantic_kernel.agents.agent_base import AgentBase @experimental_class @@ -32,12 +32,12 @@ async def receive( Args: history: The history of messages in the conversation. """ - pass + ... @abstractmethod async def invoke( self, - agent: "Agent", + agent: "AgentBase", ) -> AsyncIterable[ChatMessageContent]: """Perform a discrete incremental interaction between a single Agent and AgentChat. @@ -47,7 +47,7 @@ async def invoke( Returns: An async iterable of ChatMessageContent. """ - pass + ... @abstractmethod async def get_history( diff --git a/python/semantic_kernel/agents/chat_completion_agent.py b/python/semantic_kernel/agents/chat_completion_agent.py index d320a219f332..9ba043cb3c6c 100644 --- a/python/semantic_kernel/agents/chat_completion_agent.py +++ b/python/semantic_kernel/agents/chat_completion_agent.py @@ -3,14 +3,13 @@ import logging import sys from collections.abc import AsyncGenerator, AsyncIterable -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any if sys.version_info >= (3, 12): - from typing import override + from typing import override # pragma: no cover else: from typing_extensions import override # pragma: no cover -from pydantic import Field from semantic_kernel.agents.chat_history_kernel_agent import ChatHistoryKernelAgent from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase @@ -39,7 +38,6 @@ class ChatCompletionAgent(ChatHistoryKernelAgent): the kernel. """ - service_id: str | None = Field(default=DEFAULT_SERVICE_NAME) execution_settings: PromptExecutionSettings | None = None def __init__( @@ -63,8 +61,9 @@ def __init__( instructions: The instructions for the agent. (optional) execution_settings: The execution settings for the agent. (optional) """ - super().__init__(name=name, instructions=instructions, id=id, description=description) - self.service_id = service_id + if not service_id: + service_id = DEFAULT_SERVICE_NAME + super().__init__(service_id=service_id, name=name, instructions=instructions, id=id, description=description) self.execution_settings = execution_settings @override @@ -79,17 +78,12 @@ async def invoke(self, kernel: "Kernel", history: ChatHistory) -> AsyncIterable[ An async iterable of ChatMessageContent. """ # Get the chat completion service - service = kernel.get_service(service_id=self.service_id, type=ChatCompletionClientBase) + chat_completion_service = kernel.get_service(service_id=self.service_id, type=ChatCompletionClientBase) - if not service: + if not chat_completion_service: raise KernelServiceNotFoundError(f"Chat completion service not found with service_id: {self.service_id}") - # Cast the service to the expected type - satisfy type checking - chat_completion_service = cast(ChatCompletionClientBase, service) - - # To satisfy typechecker - if self.service_id is None: - self.service_id = DEFAULT_SERVICE_NAME # pragma: no cover + assert isinstance(chat_completion_service, ChatCompletionClientBase) # nosec settings = ( self.execution_settings @@ -128,7 +122,7 @@ async def invoke(self, kernel: "Kernel", history: ChatHistory) -> AsyncIterable[ yield message @override - async def invoke_streaming( # type: ignore + async def invoke_stream( # type: ignore self, kernel: "Kernel", history: ChatHistory ) -> AsyncGenerator[StreamingChatMessageContent, None]: """Invoke the chat history handler in streaming mode. @@ -141,17 +135,12 @@ async def invoke_streaming( # type: ignore An async generator of StreamingChatMessageContent. """ # Get the chat completion service - service = kernel.get_service(service_id=self.service_id, type=ChatCompletionClientBase) + chat_completion_service = kernel.get_service(service_id=self.service_id, type=ChatCompletionClientBase) - if not service: + if not chat_completion_service: raise KernelServiceNotFoundError(f"Chat completion service not found with service_id: {self.service_id}") - # Cast the service to the expected type - chat_completion_service = cast(ChatCompletionClientBase, service) - - # To satisfy typechecker - if self.service_id is None: - self.service_id = DEFAULT_SERVICE_NAME # pragma: no cover + assert isinstance(chat_completion_service, ChatCompletionClientBase) # nosec settings = ( self.execution_settings diff --git a/python/semantic_kernel/agents/chat_history_channel.py b/python/semantic_kernel/agents/chat_history_channel.py index c1e379ef4365..8688677c776b 100644 --- a/python/semantic_kernel/agents/chat_history_channel.py +++ b/python/semantic_kernel/agents/chat_history_channel.py @@ -1,10 +1,16 @@ # Copyright (c) Microsoft. All rights reserved. +import sys from collections.abc import AsyncIterable from pydantic import Field -from semantic_kernel.agents.agent import Agent +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_base import AgentBase from semantic_kernel.agents.agent_channel import AgentChannel from semantic_kernel.agents.chat_history_handler import ChatHistoryHandler from semantic_kernel.contents import ChatMessageContent @@ -15,15 +21,16 @@ class ChatHistoryChannel(AgentChannel): """An AgentChannel specialization for that acts upon a ChatHistoryHandler.""" - history: list[ChatMessageContent] = Field(default_factory=list, alias="history") + history: list[ChatMessageContent] = Field(default_factory=list) def __init__(self) -> None: """Initialize the ChatHistoryChannel.""" super().__init__() + @override async def invoke( # type: ignore self, - agent: Agent, + agent: AgentBase, ) -> AsyncIterable[ChatMessageContent]: """Perform a discrete incremental interaction between a single Agent and AgentChat. @@ -40,6 +47,7 @@ async def invoke( # type: ignore self.history.append(message) yield message + @override async def receive( self, history: list[ChatMessageContent], @@ -51,6 +59,7 @@ async def receive( """ self.history.extend(history) + @override async def get_history( # type: ignore self, ) -> AsyncIterable[ChatMessageContent]: diff --git a/python/semantic_kernel/agents/chat_history_handler.py b/python/semantic_kernel/agents/chat_history_handler.py index f36952adc4b8..23c7596487bb 100644 --- a/python/semantic_kernel/agents/chat_history_handler.py +++ b/python/semantic_kernel/agents/chat_history_handler.py @@ -23,7 +23,7 @@ async def invoke(self, history: ChatHistory) -> AsyncIterable[ChatMessageContent ... @abstractmethod - async def invoke_streaming(self, history: ChatHistory) -> AsyncIterable[StreamingChatMessageContent]: + async def invoke_stream(self, history: ChatHistory) -> AsyncIterable[StreamingChatMessageContent]: """Invoke the chat history handler in streaming mode. Entry point for calling into an agent from a ChatHistoryChannel for streaming content. diff --git a/python/semantic_kernel/agents/chat_history_kernel_agent.py b/python/semantic_kernel/agents/chat_history_kernel_agent.py index e165e65c88f9..178d8fe7a8c4 100644 --- a/python/semantic_kernel/agents/chat_history_kernel_agent.py +++ b/python/semantic_kernel/agents/chat_history_kernel_agent.py @@ -28,6 +28,7 @@ class ChatHistoryKernelAgent(KernelAgent, ChatHistoryHandler, ABC): def __init__( self, + service_id: str, name: str | None = None, instructions: str | None = None, id: str | None = None, @@ -36,12 +37,14 @@ def __init__( """Initialize the ChatHistoryKernelAgent. Args: + service_id: The service id for the chat completion service. name: The name of the agent. instructions: The instructions for the agent. id: The unique identifier for the agent. description: The description of the agent. """ super().__init__( + service_id=service_id, name=name, instructions=instructions, id=id, @@ -79,7 +82,7 @@ async def invoke(self, history: ChatHistory) -> AsyncIterable[ChatMessageContent ... @abstractmethod - async def invoke_streaming(self, history: ChatHistory) -> AsyncIterable[StreamingChatMessageContent]: + async def invoke_stream(self, history: ChatHistory) -> AsyncIterable[StreamingChatMessageContent]: """Invoke the chat history handler in streaming mode. Args: diff --git a/python/semantic_kernel/agents/kernel_agent.py b/python/semantic_kernel/agents/kernel_agent.py index 5f24254f26e2..bd8fa8418f64 100644 --- a/python/semantic_kernel/agents/kernel_agent.py +++ b/python/semantic_kernel/agents/kernel_agent.py @@ -1,17 +1,18 @@ # Copyright (c) Microsoft. All rights reserved. -from semantic_kernel.agents.agent import Agent +from semantic_kernel.agents.agent_base import AgentBase from semantic_kernel.utils.experimental_decorator import experimental_class @experimental_class -class KernelAgent(Agent): +class KernelAgent(AgentBase): """Base class for agents utilizing Kernel plugins or services.""" instructions: str | None = None def __init__( self, + service_id: str, name: str | None = None, instructions: str | None = None, id: str | None = None, @@ -20,10 +21,11 @@ def __init__( """Initialize the KernelAgent. Args: + service_id: The service id for the agent. name: The name of the agent. instructions: The instructions for the agent. id: The unique identifier for the agent. description: The description of the agent. """ - super().__init__(name=name, id=id, description=description) + super().__init__(service_id=service_id, name=name, id=id, description=description) self.instructions = instructions diff --git a/python/tests/unit/agents/test_agent.py b/python/tests/unit/agents/test_agent.py index 93f5986f7ea4..9842029d0dc8 100644 --- a/python/tests/unit/agents/test_agent.py +++ b/python/tests/unit/agents/test_agent.py @@ -5,11 +5,20 @@ import pytest -from semantic_kernel.agents.agent import Agent +from semantic_kernel.agents.agent_base import AgentBase from semantic_kernel.agents.agent_channel import AgentChannel -class MockAgent(Agent): +class MockAgent(AgentBase): + def __init__( + self, + service_id: str = "test_service", + name: str = "Test Agent", + description: str = "A test agent", + id: str = None, + ): + super().__init__(service_id=service_id, name=name, description=description, id=id) + def get_channel_keys(self) -> list[str]: return ["key1", "key2"] @@ -22,8 +31,9 @@ async def test_agent_initialization(): name = "Test Agent" description = "A test agent" id_value = str(uuid.uuid4()) + service_id = "test_service" - agent = MockAgent(name=name, description=description, id=id_value) + agent = MockAgent(service_id=service_id, name=name, description=description, id=id_value) assert agent.name == name assert agent.description == description diff --git a/python/tests/unit/agents/test_agent_channel.py b/python/tests/unit/agents/test_agent_channel.py index 20b61d956686..c0c8b44e0fb5 100644 --- a/python/tests/unit/agents/test_agent_channel.py +++ b/python/tests/unit/agents/test_agent_channel.py @@ -5,7 +5,7 @@ import pytest -from semantic_kernel.agents.agent import Agent +from semantic_kernel.agents.agent_base import AgentBase from semantic_kernel.agents.agent_channel import AgentChannel from semantic_kernel.contents.chat_message_content import ChatMessageContent from semantic_kernel.contents.utils.author_role import AuthorRole @@ -15,7 +15,7 @@ class MockAgentChannel(AgentChannel): async def receive(self, history: list[ChatMessageContent]) -> None: pass - async def invoke(self, agent: "Agent") -> AsyncIterable[ChatMessageContent]: + async def invoke(self, agent: "AgentBase") -> AsyncIterable[ChatMessageContent]: yield ChatMessageContent(role=AuthorRole.SYSTEM, content="test message") async def get_history(self) -> AsyncIterable[ChatMessageContent]: diff --git a/python/tests/unit/agents/test_chat_completion_agent.py b/python/tests/unit/agents/test_chat_completion_agent.py index 848e2267b5e6..860961381e8c 100644 --- a/python/tests/unit/agents/test_chat_completion_agent.py +++ b/python/tests/unit/agents/test_chat_completion_agent.py @@ -31,6 +31,22 @@ async def test_initialization(): assert agent.instructions == "Test Instructions" +@pytest.mark.asyncio +async def test_initialization_no_service_id(): + agent = ChatCompletionAgent( + name="Test Agent", + id="test_id", + description="Test Description", + instructions="Test Instructions", + ) + + assert agent.service_id == "default" + assert agent.name == "Test Agent" + assert agent.id == "test_id" + assert agent.description == "Test Description" + assert agent.instructions == "Test Instructions" + + @pytest.mark.asyncio async def test_invoke(): agent = ChatCompletionAgent(service_id="test_service", name="Test Agent", instructions="Test Instructions") @@ -101,6 +117,7 @@ async def test_invoke_streaming(): agent = ChatCompletionAgent(service_id="test_service", name="Test Agent") kernel = create_autospec(Kernel) + kernel.get_service.return_value = create_autospec(ChatCompletionClientBase) history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) @@ -110,7 +127,7 @@ async def test_invoke_streaming(): ) as mock: mock.return_value.__aiter__.return_value = [ChatMessageContent(role=AuthorRole.USER, content="Initial Message")] - async for message in agent.invoke_streaming(kernel, history): + async for message in agent.invoke_stream(kernel, history): assert message.role == AuthorRole.USER assert message.content == "Initial Message" @@ -125,7 +142,7 @@ async def test_invoke_streaming_no_service_throws(): history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) with pytest.raises(KernelServiceNotFoundError): - async for _ in agent.invoke_streaming(kernel, history): + async for _ in agent.invoke_stream(kernel, history): pass diff --git a/python/tests/unit/agents/test_chat_history_channel.py b/python/tests/unit/agents/test_chat_history_channel.py index 6164217ce077..9eb7948bbfe7 100644 --- a/python/tests/unit/agents/test_chat_history_channel.py +++ b/python/tests/unit/agents/test_chat_history_channel.py @@ -18,7 +18,7 @@ async def invoke(self, history: list[ChatMessageContent]) -> AsyncIterable[ChatM for message in history: yield ChatMessageContent(role=AuthorRole.SYSTEM, content=f"Processed: {message.content}") - async def invoke_streaming(self, history: list[ChatMessageContent]) -> AsyncIterable["StreamingChatMessageContent"]: + async def invoke_stream(self, history: list[ChatMessageContent]) -> AsyncIterable["StreamingChatMessageContent"]: pass diff --git a/python/tests/unit/agents/test_chat_history_kernel_agent.py b/python/tests/unit/agents/test_chat_history_kernel_agent.py index ab933b6e83a8..b06495fda1cf 100644 --- a/python/tests/unit/agents/test_chat_history_kernel_agent.py +++ b/python/tests/unit/agents/test_chat_history_kernel_agent.py @@ -11,11 +11,21 @@ class MockChatHistoryKernelAgent(ChatHistoryKernelAgent): + def __init__( + self, + service_id: str = "test_service", + name: str = "Test Agent", + instructions: str = "Test Instructions", + id: str = None, + description: str = "Test Description", + ): + super().__init__(service_id=service_id, name=name, instructions=instructions, id=id, description=description) + async def invoke(self, history: list[ChatMessageContent]) -> AsyncIterable[ChatMessageContent]: for message in history: yield ChatMessageContent(role=AuthorRole.SYSTEM, content=f"Processed: {message.content}") - async def invoke_streaming(self, history: list[ChatMessageContent]) -> AsyncIterable[StreamingChatMessageContent]: + async def invoke_stream(self, history: list[ChatMessageContent]) -> AsyncIterable[StreamingChatMessageContent]: pass From 9998e5e5863e076b80187ee28f18ca929efd02ef Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 10 Jul 2024 09:37:40 -0400 Subject: [PATCH 06/17] Leverage Pydantic class inits --- python/semantic_kernel/agents/agent_base.py | 25 +++++++----------- .../agents/chat_completion_agent.py | 14 ++++++++-- .../agents/chat_history_kernel_agent.py | 25 ------------------ python/semantic_kernel/agents/kernel_agent.py | 26 ++++--------------- python/tests/unit/agents/test_agent.py | 21 ++++++++------- .../unit/agents/test_chat_completion_agent.py | 8 +++--- .../agents/test_chat_history_kernel_agent.py | 12 ++++++++- 7 files changed, 53 insertions(+), 78 deletions(-) diff --git a/python/semantic_kernel/agents/agent_base.py b/python/semantic_kernel/agents/agent_base.py index a9c5edc9f198..5a5a8c5c5f5c 100644 --- a/python/semantic_kernel/agents/agent_base.py +++ b/python/semantic_kernel/agents/agent_base.py @@ -3,6 +3,8 @@ import uuid from abc import ABC, abstractmethod +from pydantic import Field + from semantic_kernel.agents.agent_channel import AgentChannel from semantic_kernel.kernel_pydantic import KernelBaseModel from semantic_kernel.utils.experimental_decorator import experimental_class @@ -16,27 +18,18 @@ class AgentBase(ABC, KernelBaseModel): A conversation may include one or more agents. In addition to identity and descriptive meta-data, an Agent must define its communication protocol, or AgentChannel. + + Attributes: + name: The name of the agent (optional). + description: The description of the agent (optional). + id: The unique identifier of the agent (optional). If no id is provided, + a new UUID will be generated. """ - service_id: str - id: str + id: str | None = Field(default_factory=lambda: str(uuid.uuid4())) description: str | None = None name: str | None = None - def __init__(self, service_id: str, name: str | None = None, description: str | None = None, id: str | None = None): - """Initialize the Agent. - - Args: - service_id: The unique identifier of the service. - name: The name of the agent (optional). - description: The description of the agent (optional). - id: The unique identifier of the agent (optional). If no id is provided, - a new UUID will be generated. - """ - if id is None: - id = str(uuid.uuid4()) - super().__init__(service_id=service_id, id=id, description=description, name=name) - @abstractmethod def get_channel_keys(self) -> list[str]: """Get the channel keys. diff --git a/python/semantic_kernel/agents/chat_completion_agent.py b/python/semantic_kernel/agents/chat_completion_agent.py index 9ba043cb3c6c..f49f09715f77 100644 --- a/python/semantic_kernel/agents/chat_completion_agent.py +++ b/python/semantic_kernel/agents/chat_completion_agent.py @@ -38,6 +38,7 @@ class ChatCompletionAgent(ChatHistoryKernelAgent): the kernel. """ + service_id: str execution_settings: PromptExecutionSettings | None = None def __init__( @@ -63,8 +64,17 @@ def __init__( """ if not service_id: service_id = DEFAULT_SERVICE_NAME - super().__init__(service_id=service_id, name=name, instructions=instructions, id=id, description=description) - self.execution_settings = execution_settings + + args = { + "service_id": service_id, + "name": name, + "description": description, + "instructions": instructions, + "execution_settings": execution_settings, + } + if id is not None: + args["id"] = id + super().__init__(**args) @override async def invoke(self, kernel: "Kernel", history: ChatHistory) -> AsyncIterable[ChatMessageContent]: # type: ignore diff --git a/python/semantic_kernel/agents/chat_history_kernel_agent.py b/python/semantic_kernel/agents/chat_history_kernel_agent.py index 178d8fe7a8c4..fd41187a2e63 100644 --- a/python/semantic_kernel/agents/chat_history_kernel_agent.py +++ b/python/semantic_kernel/agents/chat_history_kernel_agent.py @@ -26,31 +26,6 @@ class ChatHistoryKernelAgent(KernelAgent, ChatHistoryHandler, ABC): """A KernelAgent specialization bound to a ChatHistoryChannel.""" - def __init__( - self, - service_id: str, - name: str | None = None, - instructions: str | None = None, - id: str | None = None, - description: str | None = None, - ) -> None: - """Initialize the ChatHistoryKernelAgent. - - Args: - service_id: The service id for the chat completion service. - name: The name of the agent. - instructions: The instructions for the agent. - id: The unique identifier for the agent. - description: The description of the agent. - """ - super().__init__( - service_id=service_id, - name=name, - instructions=instructions, - id=id, - description=description, - ) - @override def get_channel_keys(self) -> list[str]: """Get the channel keys. diff --git a/python/semantic_kernel/agents/kernel_agent.py b/python/semantic_kernel/agents/kernel_agent.py index bd8fa8418f64..65ef2de93899 100644 --- a/python/semantic_kernel/agents/kernel_agent.py +++ b/python/semantic_kernel/agents/kernel_agent.py @@ -6,26 +6,10 @@ @experimental_class class KernelAgent(AgentBase): - """Base class for agents utilizing Kernel plugins or services.""" + """Base class for agents utilizing Kernel plugins or services. - instructions: str | None = None - - def __init__( - self, - service_id: str, - name: str | None = None, - instructions: str | None = None, - id: str | None = None, - description: str | None = None, - ) -> None: - """Initialize the KernelAgent. + Attributes: + instructions: The instructions for the agent. + """ - Args: - service_id: The service id for the agent. - name: The name of the agent. - instructions: The instructions for the agent. - id: The unique identifier for the agent. - description: The description of the agent. - """ - super().__init__(service_id=service_id, name=name, id=id, description=description) - self.instructions = instructions + instructions: str | None = None diff --git a/python/tests/unit/agents/test_agent.py b/python/tests/unit/agents/test_agent.py index 9842029d0dc8..c8367e11667b 100644 --- a/python/tests/unit/agents/test_agent.py +++ b/python/tests/unit/agents/test_agent.py @@ -10,14 +10,16 @@ class MockAgent(AgentBase): - def __init__( - self, - service_id: str = "test_service", - name: str = "Test Agent", - description: str = "A test agent", - id: str = None, - ): - super().__init__(service_id=service_id, name=name, description=description, id=id) + """A mock agent for testing purposes.""" + + def __init__(self, name: str = "Test Agent", description: str = "A test agent", id: str = None): + args = { + "name": name, + "description": description, + } + if id is not None: + args["id"] = id + super().__init__(**args) def get_channel_keys(self) -> list[str]: return ["key1", "key2"] @@ -31,9 +33,8 @@ async def test_agent_initialization(): name = "Test Agent" description = "A test agent" id_value = str(uuid.uuid4()) - service_id = "test_service" - agent = MockAgent(service_id=service_id, name=name, description=description, id=id_value) + agent = MockAgent(name=name, description=description, id=id_value) assert agent.name == name assert agent.description == description diff --git a/python/tests/unit/agents/test_chat_completion_agent.py b/python/tests/unit/agents/test_chat_completion_agent.py index 860961381e8c..5d5cc1129696 100644 --- a/python/tests/unit/agents/test_chat_completion_agent.py +++ b/python/tests/unit/agents/test_chat_completion_agent.py @@ -113,7 +113,7 @@ async def test_invoke_no_service_throws(): @pytest.mark.asyncio -async def test_invoke_streaming(): +async def test_invoke_stream(): agent = ChatCompletionAgent(service_id="test_service", name="Test Agent") kernel = create_autospec(Kernel) @@ -125,7 +125,9 @@ async def test_invoke_streaming(): "semantic_kernel.connectors.ai.chat_completion_client_base.ChatCompletionClientBase.get_streaming_chat_message_contents", return_value=AsyncMock(), ) as mock: - mock.return_value.__aiter__.return_value = [ChatMessageContent(role=AuthorRole.USER, content="Initial Message")] + mock.return_value.__aiter__.return_value = [ + [ChatMessageContent(role=AuthorRole.USER, content="Initial Message")] + ] async for message in agent.invoke_stream(kernel, history): assert message.role == AuthorRole.USER @@ -133,7 +135,7 @@ async def test_invoke_streaming(): @pytest.mark.asyncio -async def test_invoke_streaming_no_service_throws(): +async def test_invoke_stream_no_service_throws(): agent = ChatCompletionAgent(service_id="test_service", name="Test Agent") kernel = create_autospec(Kernel) diff --git a/python/tests/unit/agents/test_chat_history_kernel_agent.py b/python/tests/unit/agents/test_chat_history_kernel_agent.py index b06495fda1cf..b7504ff4d674 100644 --- a/python/tests/unit/agents/test_chat_history_kernel_agent.py +++ b/python/tests/unit/agents/test_chat_history_kernel_agent.py @@ -11,6 +11,8 @@ class MockChatHistoryKernelAgent(ChatHistoryKernelAgent): + """A mock ChatHistoryKernelAgent for testing purposes.""" + def __init__( self, service_id: str = "test_service", @@ -19,7 +21,15 @@ def __init__( id: str = None, description: str = "Test Description", ): - super().__init__(service_id=service_id, name=name, instructions=instructions, id=id, description=description) + args = { + "service_id": service_id, + "name": name, + "instructions": instructions, + "description": description, + } + if id is not None: + args["id"] = id + super().__init__(**args) async def invoke(self, history: list[ChatMessageContent]) -> AsyncIterable[ChatMessageContent]: for message in history: From 9edd6da421fac20a41110f194aeaae8a223f50e7 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 10 Jul 2024 11:01:26 -0400 Subject: [PATCH 07/17] Make the kernel an attribute of agent base. --- python/samples/concepts/agents/step1_agent.py | 14 +++--- .../samples/concepts/agents/step2_plugins.py | 18 +++---- python/semantic_kernel/agents/__init__.py | 2 - python/semantic_kernel/agents/agent_base.py | 6 ++- .../agents/chat_completion_agent.py | 20 ++++---- .../agents/chat_history_channel.py | 19 +++----- .../agents/chat_history_kernel_agent.py | 6 +-- python/semantic_kernel/agents/kernel_agent.py | 15 ------ .../unit/agents/test_chat_completion_agent.py | 47 +++++++++++++------ .../unit/agents/test_chat_history_channel.py | 14 +++--- 10 files changed, 81 insertions(+), 80 deletions(-) delete mode 100644 python/semantic_kernel/agents/kernel_agent.py diff --git a/python/samples/concepts/agents/step1_agent.py b/python/samples/concepts/agents/step1_agent.py index e329e73b0ff7..586c3ebf317c 100644 --- a/python/samples/concepts/agents/step1_agent.py +++ b/python/samples/concepts/agents/step1_agent.py @@ -22,7 +22,7 @@ PARROT_INSTRUCTIONS = "Repeat the user message in the voice of a pirate and then end with a parrot sound." -async def invoke_agent(agent: ChatCompletionAgent, input: str, kernel: Kernel, chat: ChatHistory): +async def invoke_agent(agent: ChatCompletionAgent, input: str, chat: ChatHistory): """Invoke the agent with the user input.""" chat.add_user_message(input) @@ -31,12 +31,12 @@ async def invoke_agent(agent: ChatCompletionAgent, input: str, kernel: Kernel, c if streaming: contents = [] content_name = "" - async for content in agent.invoke_stream(kernel, chat): + async for content in agent.invoke_stream(chat): content_name = content.name contents.append(content) print(f"# {content.role} - {content_name or '*'}: '{''.join([content.content for content in contents])}'") else: - async for content in agent.invoke(kernel, chat): + async for content in agent.invoke(chat): print(f"# {content.role} - {content.name or '*'}: '{content.content}'") @@ -48,15 +48,15 @@ async def main(): kernel.add_service(AzureChatCompletion(service_id="agent")) # Create the agent - agent = ChatCompletionAgent(service_id="agent", name=PARROT_NAME, instructions=PARROT_INSTRUCTIONS) + agent = ChatCompletionAgent(service_id="agent", kernel=kernel, name=PARROT_NAME, instructions=PARROT_INSTRUCTIONS) # Define the chat history chat = ChatHistory() # Respond to user input - await invoke_agent(agent, "Fortune favors the bold.", kernel, chat) - await invoke_agent(agent, "I came, I saw, I conquered.", kernel, chat) - await invoke_agent(agent, "Practice makes perfect.", kernel, chat) + await invoke_agent(agent, "Fortune favors the bold.", chat) + await invoke_agent(agent, "I came, I saw, I conquered.", chat) + await invoke_agent(agent, "Practice makes perfect.", chat) if __name__ == "__main__": diff --git a/python/samples/concepts/agents/step2_plugins.py b/python/samples/concepts/agents/step2_plugins.py index b3381229991a..70bf09040c8c 100644 --- a/python/samples/concepts/agents/step2_plugins.py +++ b/python/samples/concepts/agents/step2_plugins.py @@ -42,13 +42,13 @@ def get_item_price( # A helper method to invoke the agent with the user input -async def invoke_agent(agent: ChatCompletionAgent, input: str, kernel: Kernel, chat: ChatHistory) -> None: +async def invoke_agent(agent: ChatCompletionAgent, input: str, chat: ChatHistory) -> None: """Invoke the agent with the user input.""" chat.add_user_message(input) print(f"# {AuthorRole.USER}: '{input}'") - async for content in agent.invoke(kernel, chat): + async for content in agent.invoke(chat): print(f"# {content.role} - {content.name or '*'}: '{content.content}'") @@ -64,21 +64,21 @@ async def main(): # Configure the function choice behavior to auto invoke kernel functions settings.function_choice_behavior = FunctionChoiceBehavior.Auto() + kernel.add_plugin(plugin=MenuPlugin(), plugin_name="menu") + # Create the agent agent = ChatCompletionAgent( - service_id="agent", name=HOST_NAME, instructions=HOST_INSTRUCTIONS, execution_settings=settings + service_id="agent", kernel=kernel, name=HOST_NAME, instructions=HOST_INSTRUCTIONS, execution_settings=settings ) - kernel.add_plugin(plugin=MenuPlugin(), plugin_name="menu") - # Define the chat history chat = ChatHistory() # Respond to user input - await invoke_agent(agent, "Hello", kernel, chat) - await invoke_agent(agent, "What is the special soup?", kernel, chat) - await invoke_agent(agent, "What is the special drink?", kernel, chat) - await invoke_agent(agent, "Thank you", kernel, chat) + await invoke_agent(agent, "Hello", chat) + await invoke_agent(agent, "What is the special soup?", chat) + await invoke_agent(agent, "What is the special drink?", chat) + await invoke_agent(agent, "Thank you", chat) if __name__ == "__main__": diff --git a/python/semantic_kernel/agents/__init__.py b/python/semantic_kernel/agents/__init__.py index 5e1af76a1ea9..c86865a9011f 100644 --- a/python/semantic_kernel/agents/__init__.py +++ b/python/semantic_kernel/agents/__init__.py @@ -6,7 +6,6 @@ from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel from semantic_kernel.agents.chat_history_handler import ChatHistoryHandler from semantic_kernel.agents.chat_history_kernel_agent import ChatHistoryKernelAgent -from semantic_kernel.agents.kernel_agent import KernelAgent __all__ = [ "AgentBase", @@ -15,5 +14,4 @@ "ChatHistoryChannel", "ChatHistoryHandler", "ChatHistoryKernelAgent", - "KernelAgent", ] diff --git a/python/semantic_kernel/agents/agent_base.py b/python/semantic_kernel/agents/agent_base.py index 5a5a8c5c5f5c..8df4f5b04586 100644 --- a/python/semantic_kernel/agents/agent_base.py +++ b/python/semantic_kernel/agents/agent_base.py @@ -6,6 +6,7 @@ from pydantic import Field from semantic_kernel.agents.agent_channel import AgentChannel +from semantic_kernel.kernel import Kernel from semantic_kernel.kernel_pydantic import KernelBaseModel from semantic_kernel.utils.experimental_decorator import experimental_class @@ -24,11 +25,14 @@ class AgentBase(ABC, KernelBaseModel): description: The description of the agent (optional). id: The unique identifier of the agent (optional). If no id is provided, a new UUID will be generated. + instructions: The instructions for the agent (optional """ - id: str | None = Field(default_factory=lambda: str(uuid.uuid4())) + id: str = Field(default_factory=lambda: str(uuid.uuid4())) description: str | None = None name: str | None = None + instructions: str | None = None + kernel: Kernel = Field(default_factory=Kernel) @abstractmethod def get_channel_keys(self) -> list[str]: diff --git a/python/semantic_kernel/agents/chat_completion_agent.py b/python/semantic_kernel/agents/chat_completion_agent.py index f49f09715f77..63e52037c5b5 100644 --- a/python/semantic_kernel/agents/chat_completion_agent.py +++ b/python/semantic_kernel/agents/chat_completion_agent.py @@ -44,6 +44,7 @@ class ChatCompletionAgent(ChatHistoryKernelAgent): def __init__( self, service_id: str | None = None, + kernel: "Kernel | None" = None, name: str | None = None, id: str | None = None, description: str | None = None, @@ -55,6 +56,7 @@ def __init__( Args: service_id: The service id for the chat completion service. (optional) If not provided, the default service name `default` will be used. + kernel: The kernel instance. (optional) name: The name of the agent. (optional) id: The unique identifier for the agent. (optional) If not provided, a unique GUID will be generated. @@ -74,10 +76,12 @@ def __init__( } if id is not None: args["id"] = id + if kernel is not None: + args["kernel"] = kernel # type: ignore super().__init__(**args) @override - async def invoke(self, kernel: "Kernel", history: ChatHistory) -> AsyncIterable[ChatMessageContent]: # type: ignore + async def invoke(self, history: ChatHistory) -> AsyncIterable[ChatMessageContent]: # type: ignore """Invoke the chat history handler. Args: @@ -88,7 +92,7 @@ async def invoke(self, kernel: "Kernel", history: ChatHistory) -> AsyncIterable[ An async iterable of ChatMessageContent. """ # Get the chat completion service - chat_completion_service = kernel.get_service(service_id=self.service_id, type=ChatCompletionClientBase) + chat_completion_service = self.kernel.get_service(service_id=self.service_id, type=ChatCompletionClientBase) if not chat_completion_service: raise KernelServiceNotFoundError(f"Chat completion service not found with service_id: {self.service_id}") @@ -97,7 +101,7 @@ async def invoke(self, kernel: "Kernel", history: ChatHistory) -> AsyncIterable[ settings = ( self.execution_settings - or kernel.get_prompt_execution_settings_from_service_id(self.service_id) + or self.kernel.get_prompt_execution_settings_from_service_id(self.service_id) or chat_completion_service.instantiate_prompt_execution_settings( service_id=self.service_id, extension_data={"ai_model_id": chat_completion_service.ai_model_id} ) @@ -112,7 +116,7 @@ async def invoke(self, kernel: "Kernel", history: ChatHistory) -> AsyncIterable[ messages = await chat_completion_service.get_chat_message_contents( chat_history=chat, settings=settings, - kernel=kernel, + kernel=self.kernel, arguments=KernelArguments(), ) @@ -133,7 +137,7 @@ async def invoke(self, kernel: "Kernel", history: ChatHistory) -> AsyncIterable[ @override async def invoke_stream( # type: ignore - self, kernel: "Kernel", history: ChatHistory + self, history: ChatHistory ) -> AsyncGenerator[StreamingChatMessageContent, None]: """Invoke the chat history handler in streaming mode. @@ -145,7 +149,7 @@ async def invoke_stream( # type: ignore An async generator of StreamingChatMessageContent. """ # Get the chat completion service - chat_completion_service = kernel.get_service(service_id=self.service_id, type=ChatCompletionClientBase) + chat_completion_service = self.kernel.get_service(service_id=self.service_id, type=ChatCompletionClientBase) if not chat_completion_service: raise KernelServiceNotFoundError(f"Chat completion service not found with service_id: {self.service_id}") @@ -154,7 +158,7 @@ async def invoke_stream( # type: ignore settings = ( self.execution_settings - or kernel.get_prompt_execution_settings_from_service_id(self.service_id) + or self.kernel.get_prompt_execution_settings_from_service_id(self.service_id) or chat_completion_service.instantiate_prompt_execution_settings( service_id=self.service_id, extension_data={"ai_model_id": chat_completion_service.ai_model_id} ) @@ -170,7 +174,7 @@ async def invoke_stream( # type: ignore chat_completion_service.get_streaming_chat_message_contents( chat_history=chat, settings=settings, - kernel=kernel, + kernel=self.kernel, arguments=KernelArguments(), ) ) diff --git a/python/semantic_kernel/agents/chat_history_channel.py b/python/semantic_kernel/agents/chat_history_channel.py index 8688677c776b..529e98c56e8e 100644 --- a/python/semantic_kernel/agents/chat_history_channel.py +++ b/python/semantic_kernel/agents/chat_history_channel.py @@ -3,8 +3,6 @@ import sys from collections.abc import AsyncIterable -from pydantic import Field - if sys.version_info >= (3, 12): from typing import override # pragma: no cover else: @@ -14,19 +12,14 @@ from semantic_kernel.agents.agent_channel import AgentChannel from semantic_kernel.agents.chat_history_handler import ChatHistoryHandler from semantic_kernel.contents import ChatMessageContent +from semantic_kernel.contents.chat_history import ChatHistory from semantic_kernel.utils.experimental_decorator import experimental_class @experimental_class -class ChatHistoryChannel(AgentChannel): +class ChatHistoryChannel(AgentChannel, ChatHistory): """An AgentChannel specialization for that acts upon a ChatHistoryHandler.""" - history: list[ChatMessageContent] = Field(default_factory=list) - - def __init__(self) -> None: - """Initialize the ChatHistoryChannel.""" - super().__init__() - @override async def invoke( # type: ignore self, @@ -43,8 +36,8 @@ async def invoke( # type: ignore if not isinstance(agent, ChatHistoryHandler): raise ValueError(f"Invalid channel binding for agent: {agent.id} ({type(agent).__name__})") - async for message in agent.invoke(self.history): - self.history.append(message) + async for message in agent.invoke(self.messages): + self.messages.append(message) yield message @override @@ -57,7 +50,7 @@ async def receive( Args: history: The history of messages in the conversation. """ - self.history.extend(history) + self.messages.extend(history) @override async def get_history( # type: ignore @@ -68,5 +61,5 @@ async def get_history( # type: ignore Returns: An async iterable of ChatMessageContent. """ - for message in reversed(self.history): + for message in reversed(self.messages): yield message diff --git a/python/semantic_kernel/agents/chat_history_kernel_agent.py b/python/semantic_kernel/agents/chat_history_kernel_agent.py index fd41187a2e63..d99903a583ed 100644 --- a/python/semantic_kernel/agents/chat_history_kernel_agent.py +++ b/python/semantic_kernel/agents/chat_history_kernel_agent.py @@ -10,10 +10,10 @@ else: from typing_extensions import override # pragma: no cover +from semantic_kernel.agents.agent_base import AgentBase from semantic_kernel.agents.agent_channel import AgentChannel from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel from semantic_kernel.agents.chat_history_handler import ChatHistoryHandler -from semantic_kernel.agents.kernel_agent import KernelAgent 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 @@ -23,7 +23,7 @@ @experimental_class -class ChatHistoryKernelAgent(KernelAgent, ChatHistoryHandler, ABC): +class ChatHistoryKernelAgent(AgentBase, ChatHistoryHandler, ABC): """A KernelAgent specialization bound to a ChatHistoryChannel.""" @override @@ -42,7 +42,7 @@ def create_channel(self) -> AgentChannel: # type: ignore Returns: An instance of AgentChannel. """ - return ChatHistoryChannel() + return ChatHistoryChannel(messages=[]) @abstractmethod async def invoke(self, history: ChatHistory) -> AsyncIterable[ChatMessageContent]: diff --git a/python/semantic_kernel/agents/kernel_agent.py b/python/semantic_kernel/agents/kernel_agent.py deleted file mode 100644 index 65ef2de93899..000000000000 --- a/python/semantic_kernel/agents/kernel_agent.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from semantic_kernel.agents.agent_base import AgentBase -from semantic_kernel.utils.experimental_decorator import experimental_class - - -@experimental_class -class KernelAgent(AgentBase): - """Base class for agents utilizing Kernel plugins or services. - - Attributes: - instructions: The instructions for the agent. - """ - - instructions: str | None = None diff --git a/python/tests/unit/agents/test_chat_completion_agent.py b/python/tests/unit/agents/test_chat_completion_agent.py index 5d5cc1129696..83249f0bb681 100644 --- a/python/tests/unit/agents/test_chat_completion_agent.py +++ b/python/tests/unit/agents/test_chat_completion_agent.py @@ -41,6 +41,7 @@ async def test_initialization_no_service_id(): ) assert agent.service_id == "default" + assert agent.kernel is not None assert agent.name == "Test Agent" assert agent.id == "test_id" assert agent.description == "Test Description" @@ -48,18 +49,37 @@ async def test_initialization_no_service_id(): @pytest.mark.asyncio -async def test_invoke(): - agent = ChatCompletionAgent(service_id="test_service", name="Test Agent", instructions="Test Instructions") +async def test_initialization_with_kernel(kernel: Kernel): + agent = ChatCompletionAgent( + kernel=kernel, + name="Test Agent", + id="test_id", + description="Test Description", + instructions="Test Instructions", + ) + assert agent.service_id == "default" + assert kernel == agent.kernel + assert agent.name == "Test Agent" + assert agent.id == "test_id" + assert agent.description == "Test Description" + assert agent.instructions == "Test Instructions" + + +@pytest.mark.asyncio +async def test_invoke(): kernel = create_autospec(Kernel) kernel.get_service.return_value = create_autospec(ChatCompletionClientBase) kernel.get_service.return_value.get_chat_message_contents = AsyncMock( return_value=[ChatMessageContent(role=AuthorRole.SYSTEM, content="Processed Message")] ) + agent = ChatCompletionAgent( + kernel=kernel, service_id="test_service", name="Test Agent", instructions="Test Instructions" + ) history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) - messages = [message async for message in agent.invoke(kernel, history)] + messages = [message async for message in agent.invoke(history)] assert len(messages) == 1 assert messages[0].content == "Processed Message" @@ -67,11 +87,10 @@ async def test_invoke(): @pytest.mark.asyncio async def test_invoke_tool_call_added(): - agent = ChatCompletionAgent(service_id="test_service", name="Test Agent") - kernel = create_autospec(Kernel) chat_completion_service = create_autospec(ChatCompletionClientBase) kernel.get_service.return_value = chat_completion_service + agent = ChatCompletionAgent(kernel=kernel, service_id="test_service", name="Test Agent") history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) @@ -85,7 +104,7 @@ async def mock_get_chat_message_contents(chat_history, settings, kernel, argumen chat_completion_service.get_chat_message_contents = AsyncMock(side_effect=mock_get_chat_message_contents) - messages = [message async for message in agent.invoke(kernel, history)] + messages = [message async for message in agent.invoke(history)] assert len(messages) == 2 assert messages[0].content == "Processed Message 1" @@ -100,25 +119,24 @@ async def mock_get_chat_message_contents(chat_history, settings, kernel, argumen @pytest.mark.asyncio async def test_invoke_no_service_throws(): - agent = ChatCompletionAgent(service_id="test_service", name="Test Agent") - kernel = create_autospec(Kernel) kernel.get_service.return_value = None + agent = ChatCompletionAgent(kernel=kernel, service_id="test_service", name="Test Agent") history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) with pytest.raises(KernelServiceNotFoundError): - async for _ in agent.invoke(kernel, history): + async for _ in agent.invoke(history): pass @pytest.mark.asyncio async def test_invoke_stream(): - agent = ChatCompletionAgent(service_id="test_service", name="Test Agent") - kernel = create_autospec(Kernel) kernel.get_service.return_value = create_autospec(ChatCompletionClientBase) + agent = ChatCompletionAgent(kernel=kernel, service_id="test_service", name="Test Agent") + history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) with patch( @@ -129,22 +147,21 @@ async def test_invoke_stream(): [ChatMessageContent(role=AuthorRole.USER, content="Initial Message")] ] - async for message in agent.invoke_stream(kernel, history): + async for message in agent.invoke_stream(history): assert message.role == AuthorRole.USER assert message.content == "Initial Message" @pytest.mark.asyncio async def test_invoke_stream_no_service_throws(): - agent = ChatCompletionAgent(service_id="test_service", name="Test Agent") - kernel = create_autospec(Kernel) kernel.get_service.return_value = None + agent = ChatCompletionAgent(kernel=kernel, service_id="test_service", name="Test Agent") history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) with pytest.raises(KernelServiceNotFoundError): - async for _ in agent.invoke_stream(kernel, history): + async for _ in agent.invoke_stream(history): pass diff --git a/python/tests/unit/agents/test_chat_history_channel.py b/python/tests/unit/agents/test_chat_history_channel.py index 9eb7948bbfe7..b674f3216772 100644 --- a/python/tests/unit/agents/test_chat_history_channel.py +++ b/python/tests/unit/agents/test_chat_history_channel.py @@ -34,7 +34,7 @@ async def test_invoke(): agent = MockChatHistoryHandler() initial_message = ChatMessageContent(role=AuthorRole.USER, content="Initial message") - channel.history.append(initial_message) + channel.messages.append(initial_message) received_messages = [] async for message in channel.invoke(agent): @@ -65,11 +65,11 @@ async def test_receive(): await channel.receive(history) - assert len(channel.history) == 2 - assert channel.history[0].content == "test message 1" - assert channel.history[0].role == AuthorRole.SYSTEM - assert channel.history[1].content == "test message 2" - assert channel.history[1].role == AuthorRole.USER + assert len(channel.messages) == 2 + assert channel.messages[0].content == "test message 1" + assert channel.messages[0].role == AuthorRole.SYSTEM + assert channel.messages[1].content == "test message 2" + assert channel.messages[1].role == AuthorRole.USER @pytest.mark.asyncio @@ -79,7 +79,7 @@ async def test_get_history(): ChatMessageContent(role=AuthorRole.SYSTEM, content="test message 1"), ChatMessageContent(role=AuthorRole.USER, content="test message 2"), ] - channel.history.extend(history) + channel.messages.extend(history) messages = [message async for message in channel.get_history()] From 6a71a9b283bd7c91f385c4e3cf64f97ce08b8b04 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 10 Jul 2024 12:43:47 -0400 Subject: [PATCH 08/17] Add streaming concept message to chat history. --- python/samples/concepts/agents/step2_plugins.py | 17 +++++++++++++++-- .../agents/chat_completion_agent.py | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/python/samples/concepts/agents/step2_plugins.py b/python/samples/concepts/agents/step2_plugins.py index 70bf09040c8c..2b21e147159a 100644 --- a/python/samples/concepts/agents/step2_plugins.py +++ b/python/samples/concepts/agents/step2_plugins.py @@ -17,6 +17,9 @@ # the Kernel. # ################################################################### +# This sample allows for a streaming response verus a non-streaming response +streaming = True + # Define the agent name and instructions HOST_NAME = "Host" HOST_INSTRUCTIONS = "Answer questions about the menu." @@ -48,8 +51,18 @@ async def invoke_agent(agent: ChatCompletionAgent, input: str, chat: ChatHistory print(f"# {AuthorRole.USER}: '{input}'") - async for content in agent.invoke(chat): - print(f"# {content.role} - {content.name or '*'}: '{content.content}'") + if streaming: + contents = [] + content_name = "" + async for content in agent.invoke_stream(chat): + content_name = content.name + contents.append(content) + message_content = "".join([content.content for content in contents]) + print(f"# {content.role} - {content_name or '*'}: '{message_content}'") + chat.add_assistant_message(message_content) + else: + async for content in agent.invoke(chat): + print(f"# {content.role} - {content.name or '*'}: '{content.content}'") async def main(): diff --git a/python/semantic_kernel/agents/chat_completion_agent.py b/python/semantic_kernel/agents/chat_completion_agent.py index 63e52037c5b5..e2f87b7305fe 100644 --- a/python/semantic_kernel/agents/chat_completion_agent.py +++ b/python/semantic_kernel/agents/chat_completion_agent.py @@ -138,7 +138,7 @@ async def invoke(self, history: ChatHistory) -> AsyncIterable[ChatMessageContent @override async def invoke_stream( # type: ignore self, history: ChatHistory - ) -> AsyncGenerator[StreamingChatMessageContent, None]: + ) -> AsyncIterable[StreamingChatMessageContent]: """Invoke the chat history handler in streaming mode. Args: From dba986056357fad4eeed8d04180b983113ba38a7 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 10 Jul 2024 16:02:26 -0400 Subject: [PATCH 09/17] Improve unit test coverage. Fix order of streaming chat history aggregation. --- .../samples/concepts/agents/step2_plugins.py | 1 + .../agents/chat_completion_agent.py | 13 ++++--- .../agents/chat_history_channel.py | 6 +++- .../unit/agents/test_chat_completion_agent.py | 34 +++++++++++++++++++ .../unit/agents/test_chat_history_channel.py | 3 +- 5 files changed, 48 insertions(+), 9 deletions(-) diff --git a/python/samples/concepts/agents/step2_plugins.py b/python/samples/concepts/agents/step2_plugins.py index 2b21e147159a..6abdc2afba10 100644 --- a/python/samples/concepts/agents/step2_plugins.py +++ b/python/samples/concepts/agents/step2_plugins.py @@ -63,6 +63,7 @@ async def invoke_agent(agent: ChatCompletionAgent, input: str, chat: ChatHistory else: async for content in agent.invoke(chat): print(f"# {content.role} - {content.name or '*'}: '{content.content}'") + chat.add_assistant_message(content.content) async def main(): diff --git a/python/semantic_kernel/agents/chat_completion_agent.py b/python/semantic_kernel/agents/chat_completion_agent.py index e2f87b7305fe..b4b1c4270ee4 100644 --- a/python/semantic_kernel/agents/chat_completion_agent.py +++ b/python/semantic_kernel/agents/chat_completion_agent.py @@ -10,7 +10,6 @@ else: from typing_extensions import override # pragma: no cover - from semantic_kernel.agents.chat_history_kernel_agent import ChatHistoryKernelAgent from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings @@ -184,17 +183,17 @@ async def invoke_stream( # type: ignore f"with message count: {message_count}." ) - # Capture mutated messages related function calling / tools - for message_index in range(message_count, len(chat)): - message = chat[message_index] - message.name = self.name - history.add_message(message) - async for message_list in messages: for message in message_list: message.name = self.name yield message + # Capture mutated messages related function calling / tools + for message_index in range(message_count, len(chat)): + message = chat[message_index] # type: ignore + message.name = self.name + history.add_message(message) + def _setup_agent_chat_history(self, history: ChatHistory) -> ChatHistory: """Setup the agent chat history.""" chat = [] diff --git a/python/semantic_kernel/agents/chat_history_channel.py b/python/semantic_kernel/agents/chat_history_channel.py index 529e98c56e8e..0e90e69aa866 100644 --- a/python/semantic_kernel/agents/chat_history_channel.py +++ b/python/semantic_kernel/agents/chat_history_channel.py @@ -13,6 +13,7 @@ from semantic_kernel.agents.chat_history_handler import ChatHistoryHandler from semantic_kernel.contents import ChatMessageContent from semantic_kernel.contents.chat_history import ChatHistory +from semantic_kernel.exceptions import ServiceInvalidTypeError from semantic_kernel.utils.experimental_decorator import experimental_class @@ -34,7 +35,10 @@ async def invoke( # type: ignore An async iterable of ChatMessageContent. """ if not isinstance(agent, ChatHistoryHandler): - raise ValueError(f"Invalid channel binding for agent: {agent.id} ({type(agent).__name__})") + raise ServiceInvalidTypeError( + f"Invalid channel binding for agent: " + f"{agent.id if hasattr(agent, "id") else ""} ({type(agent).__name__})" + ) async for message in agent.invoke(self.messages): self.messages.append(message) diff --git a/python/tests/unit/agents/test_chat_completion_agent.py b/python/tests/unit/agents/test_chat_completion_agent.py index 83249f0bb681..fe84b78d2a92 100644 --- a/python/tests/unit/agents/test_chat_completion_agent.py +++ b/python/tests/unit/agents/test_chat_completion_agent.py @@ -14,6 +14,21 @@ from semantic_kernel.kernel import Kernel +@pytest.fixture +def mock_streaming_chat_completion_response() -> AsyncMock: + """A fixture that returns a mock response for a streaming chat completion response.""" + + async def mock_response(chat_history, settings, kernel, arguments): + content1 = ChatMessageContent(role=AuthorRole.SYSTEM, content="Processed Message 1") + content2 = ChatMessageContent(role=AuthorRole.TOOL, content="Processed Message 2") + chat_history.messages.append(content1) + chat_history.messages.append(content2) + yield [content1] + yield [content2] + + return mock_response + + @pytest.mark.asyncio async def test_initialization(): agent = ChatCompletionAgent( @@ -152,6 +167,25 @@ async def test_invoke_stream(): assert message.content == "Initial Message" +@pytest.mark.asyncio +async def test_invoke_stream_tool_call_added(mock_streaming_chat_completion_response): + kernel = create_autospec(Kernel) + chat_completion_service = create_autospec(ChatCompletionClientBase) + kernel.get_service.return_value = chat_completion_service + agent = ChatCompletionAgent(kernel=kernel, service_id="test_service", name="Test Agent") + + history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) + + chat_completion_service.get_streaming_chat_message_contents = mock_streaming_chat_completion_response + + async for message in agent.invoke_stream(history): + print(f"Message role: {message.role}, content: {message.content}") + assert message.role in [AuthorRole.SYSTEM, AuthorRole.TOOL] + assert message.content in ["Processed Message 1", "Processed Message 2"] + + assert len(history.messages) == 3 + + @pytest.mark.asyncio async def test_invoke_stream_no_service_throws(): kernel = create_autospec(Kernel) diff --git a/python/tests/unit/agents/test_chat_history_channel.py b/python/tests/unit/agents/test_chat_history_channel.py index b674f3216772..49d374a37894 100644 --- a/python/tests/unit/agents/test_chat_history_channel.py +++ b/python/tests/unit/agents/test_chat_history_channel.py @@ -9,6 +9,7 @@ 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 import ServiceInvalidTypeError class MockChatHistoryHandler(ChatHistoryHandler): @@ -50,7 +51,7 @@ async def test_invoke_incorrect_instance_throws(): channel = ChatHistoryChannel() agent = MockNonChatHistoryHandler() - with pytest.raises(ValueError): + with pytest.raises(ServiceInvalidTypeError): async for _ in channel.invoke(agent): pass From c4dcd615d731d86e2cdded716feca152d75f6236 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 10 Jul 2024 16:08:42 -0400 Subject: [PATCH 10/17] Fix ruff --- python/semantic_kernel/agents/chat_history_channel.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/python/semantic_kernel/agents/chat_history_channel.py b/python/semantic_kernel/agents/chat_history_channel.py index 0e90e69aa866..dc91c52c4cb3 100644 --- a/python/semantic_kernel/agents/chat_history_channel.py +++ b/python/semantic_kernel/agents/chat_history_channel.py @@ -36,8 +36,7 @@ async def invoke( # type: ignore """ if not isinstance(agent, ChatHistoryHandler): raise ServiceInvalidTypeError( - f"Invalid channel binding for agent: " - f"{agent.id if hasattr(agent, "id") else ""} ({type(agent).__name__})" + f"Invalid channel binding for agent: {agent.id if hasattr(agent, "id") else ""} ({type(agent).__name__})" # noqa: E501 ) async for message in agent.invoke(self.messages): From b13e0b1a3b85f873a5b97cbc5cf00f4a8781dd48 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 10 Jul 2024 16:23:10 -0400 Subject: [PATCH 11/17] Fix exception logging --- python/semantic_kernel/agents/chat_history_channel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/semantic_kernel/agents/chat_history_channel.py b/python/semantic_kernel/agents/chat_history_channel.py index dc91c52c4cb3..d20683d3b31e 100644 --- a/python/semantic_kernel/agents/chat_history_channel.py +++ b/python/semantic_kernel/agents/chat_history_channel.py @@ -35,8 +35,9 @@ async def invoke( # type: ignore An async iterable of ChatMessageContent. """ if not isinstance(agent, ChatHistoryHandler): + id = agent.id if hasattr(agent, "id") else "" raise ServiceInvalidTypeError( - f"Invalid channel binding for agent: {agent.id if hasattr(agent, "id") else ""} ({type(agent).__name__})" # noqa: E501 + f"Invalid channel binding for agent with id: `{id}` with name: ({type(agent).__name__})" ) async for message in agent.invoke(self.messages): From e26fe13b08485b07eeb42330fc0eac8b6e7ce929 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 10 Jul 2024 16:59:51 -0400 Subject: [PATCH 12/17] PR feedback --- python/samples/concepts/agents/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/samples/concepts/agents/README.md b/python/samples/concepts/agents/README.md index 70b55138950a..46a69a539633 100644 --- a/python/samples/concepts/agents/README.md +++ b/python/samples/concepts/agents/README.md @@ -21,7 +21,7 @@ Example|Description ## Configuring the Kernel Similar to the Semantic Kernel Python concept samples, it is necessary to configure the secrets -and keys used by the kernel. See the follow "Configuring the Kernel" [guide](../README.md) for +and keys used by the kernel. See the follow "Configuring the Kernel" [guide](../README.md#configuring-the-kernel) for more information. ## Running Concept Samples From e5e5e023585b2d2445df324ebbe6ed2b54a9c986 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 11 Jul 2024 08:57:17 -0400 Subject: [PATCH 13/17] Address PR feedback --- python/semantic_kernel/agents/__init__.py | 10 ---------- .../agents/{agent_base.py => agent.py} | 9 ++++++--- python/semantic_kernel/agents/agent_channel.py | 14 +++++++------- .../agents/chat_history_channel.py | 7 ++++--- .../agents/chat_history_handler.py | 16 +++++++++------- .../agents/chat_history_kernel_agent.py | 4 ++-- python/tests/unit/agents/test_agent.py | 4 ++-- python/tests/unit/agents/test_agent_channel.py | 4 ++-- .../unit/agents/test_chat_completion_agent.py | 2 +- 9 files changed, 33 insertions(+), 37 deletions(-) rename python/semantic_kernel/agents/{agent_base.py => agent.py} (87%) diff --git a/python/semantic_kernel/agents/__init__.py b/python/semantic_kernel/agents/__init__.py index c86865a9011f..376202f33570 100644 --- a/python/semantic_kernel/agents/__init__.py +++ b/python/semantic_kernel/agents/__init__.py @@ -1,17 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. -from semantic_kernel.agents.agent_base import AgentBase -from semantic_kernel.agents.agent_channel import AgentChannel from semantic_kernel.agents.chat_completion_agent import ChatCompletionAgent -from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel -from semantic_kernel.agents.chat_history_handler import ChatHistoryHandler -from semantic_kernel.agents.chat_history_kernel_agent import ChatHistoryKernelAgent __all__ = [ - "AgentBase", - "AgentChannel", "ChatCompletionAgent", - "ChatHistoryChannel", - "ChatHistoryHandler", - "ChatHistoryKernelAgent", ] diff --git a/python/semantic_kernel/agents/agent_base.py b/python/semantic_kernel/agents/agent.py similarity index 87% rename from python/semantic_kernel/agents/agent_base.py rename to python/semantic_kernel/agents/agent.py index 8df4f5b04586..074fbbb72195 100644 --- a/python/semantic_kernel/agents/agent_base.py +++ b/python/semantic_kernel/agents/agent.py @@ -2,17 +2,20 @@ import uuid from abc import ABC, abstractmethod +from typing import TYPE_CHECKING from pydantic import Field -from semantic_kernel.agents.agent_channel import AgentChannel from semantic_kernel.kernel import Kernel from semantic_kernel.kernel_pydantic import KernelBaseModel from semantic_kernel.utils.experimental_decorator import experimental_class +if TYPE_CHECKING: + from semantic_kernel.agents.agent_channel import AgentChannel + @experimental_class -class AgentBase(ABC, KernelBaseModel): +class Agent(ABC, KernelBaseModel): """Base abstraction for all Semantic Kernel agents. An agent instance may participate in one or more conversations. @@ -44,7 +47,7 @@ def get_channel_keys(self) -> list[str]: ... @abstractmethod - async def create_channel(self) -> AgentChannel: + async def create_channel(self) -> "AgentChannel": """Create a channel. Returns: diff --git a/python/semantic_kernel/agents/agent_channel.py b/python/semantic_kernel/agents/agent_channel.py index bffa95280bc4..1b288f678a23 100644 --- a/python/semantic_kernel/agents/agent_channel.py +++ b/python/semantic_kernel/agents/agent_channel.py @@ -6,11 +6,11 @@ from pydantic import BaseModel -from semantic_kernel.contents.chat_message_content import ChatMessageContent from semantic_kernel.utils.experimental_decorator import experimental_class if TYPE_CHECKING: - from semantic_kernel.agents.agent_base import AgentBase + from semantic_kernel.agents.agent import Agent + from semantic_kernel.contents.chat_message_content import ChatMessageContent @experimental_class @@ -23,7 +23,7 @@ class AgentChannel(ABC, BaseModel): @abstractmethod async def receive( self, - history: list[ChatMessageContent], + history: list["ChatMessageContent"], ) -> None: """Receive the conversation messages. @@ -37,8 +37,8 @@ async def receive( @abstractmethod async def invoke( self, - agent: "AgentBase", - ) -> AsyncIterable[ChatMessageContent]: + agent: "Agent", + ) -> AsyncIterable["ChatMessageContent"]: """Perform a discrete incremental interaction between a single Agent and AgentChat. Args: @@ -52,10 +52,10 @@ async def invoke( @abstractmethod async def get_history( self, - ) -> AsyncIterable[ChatMessageContent]: + ) -> AsyncIterable["ChatMessageContent"]: """Retrieve the message history specific to this channel. Returns: An async iterable of ChatMessageContent. """ - pass + ... diff --git a/python/semantic_kernel/agents/chat_history_channel.py b/python/semantic_kernel/agents/chat_history_channel.py index d20683d3b31e..de769dc09beb 100644 --- a/python/semantic_kernel/agents/chat_history_channel.py +++ b/python/semantic_kernel/agents/chat_history_channel.py @@ -8,7 +8,7 @@ else: from typing_extensions import override # pragma: no cover -from semantic_kernel.agents.agent_base import AgentBase +from semantic_kernel.agents.agent import Agent from semantic_kernel.agents.agent_channel import AgentChannel from semantic_kernel.agents.chat_history_handler import ChatHistoryHandler from semantic_kernel.contents import ChatMessageContent @@ -24,7 +24,7 @@ class ChatHistoryChannel(AgentChannel, ChatHistory): @override async def invoke( # type: ignore self, - agent: AgentBase, + agent: Agent, ) -> AsyncIterable[ChatMessageContent]: """Perform a discrete incremental interaction between a single Agent and AgentChat. @@ -40,7 +40,8 @@ async def invoke( # type: ignore f"Invalid channel binding for agent with id: `{id}` with name: ({type(agent).__name__})" ) - async for message in agent.invoke(self.messages): + # Type checker does not recognize the async for loop + async for message in agent.invoke(self): # type: ignore self.messages.append(message) yield message diff --git a/python/semantic_kernel/agents/chat_history_handler.py b/python/semantic_kernel/agents/chat_history_handler.py index 23c7596487bb..95b06d3a195a 100644 --- a/python/semantic_kernel/agents/chat_history_handler.py +++ b/python/semantic_kernel/agents/chat_history_handler.py @@ -2,20 +2,22 @@ from abc import ABC, abstractmethod from collections.abc import AsyncIterable +from typing import TYPE_CHECKING -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.kernel_pydantic import KernelBaseModel from semantic_kernel.utils.experimental_decorator import experimental_class +if TYPE_CHECKING: + 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 + @experimental_class -class ChatHistoryHandler(ABC, KernelBaseModel): +class ChatHistoryHandler(ABC): """Contract for an agent that utilizes a ChatHistoryChannel.""" @abstractmethod - async def invoke(self, history: ChatHistory) -> AsyncIterable[ChatMessageContent]: + async def invoke(self, history: "ChatHistory") -> AsyncIterable["ChatMessageContent"]: """Invoke the chat history handler. Entry point for calling into an agent from a ChatHistoryChannel @@ -23,7 +25,7 @@ async def invoke(self, history: ChatHistory) -> AsyncIterable[ChatMessageContent ... @abstractmethod - async def invoke_stream(self, history: ChatHistory) -> AsyncIterable[StreamingChatMessageContent]: + async def invoke_stream(self, history: "ChatHistory") -> AsyncIterable["StreamingChatMessageContent"]: """Invoke the chat history handler in streaming mode. Entry point for calling into an agent from a ChatHistoryChannel for streaming content. diff --git a/python/semantic_kernel/agents/chat_history_kernel_agent.py b/python/semantic_kernel/agents/chat_history_kernel_agent.py index d99903a583ed..3bf328b35697 100644 --- a/python/semantic_kernel/agents/chat_history_kernel_agent.py +++ b/python/semantic_kernel/agents/chat_history_kernel_agent.py @@ -10,7 +10,7 @@ else: from typing_extensions import override # pragma: no cover -from semantic_kernel.agents.agent_base import AgentBase +from semantic_kernel.agents.agent import Agent from semantic_kernel.agents.agent_channel import AgentChannel from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel from semantic_kernel.agents.chat_history_handler import ChatHistoryHandler @@ -23,7 +23,7 @@ @experimental_class -class ChatHistoryKernelAgent(AgentBase, ChatHistoryHandler, ABC): +class ChatHistoryKernelAgent(Agent, ChatHistoryHandler, ABC): """A KernelAgent specialization bound to a ChatHistoryChannel.""" @override diff --git a/python/tests/unit/agents/test_agent.py b/python/tests/unit/agents/test_agent.py index c8367e11667b..6094b649e1e7 100644 --- a/python/tests/unit/agents/test_agent.py +++ b/python/tests/unit/agents/test_agent.py @@ -5,11 +5,11 @@ import pytest -from semantic_kernel.agents.agent_base import AgentBase +from semantic_kernel.agents.agent import Agent from semantic_kernel.agents.agent_channel import AgentChannel -class MockAgent(AgentBase): +class MockAgent(Agent): """A mock agent for testing purposes.""" def __init__(self, name: str = "Test Agent", description: str = "A test agent", id: str = None): diff --git a/python/tests/unit/agents/test_agent_channel.py b/python/tests/unit/agents/test_agent_channel.py index c0c8b44e0fb5..20b61d956686 100644 --- a/python/tests/unit/agents/test_agent_channel.py +++ b/python/tests/unit/agents/test_agent_channel.py @@ -5,7 +5,7 @@ import pytest -from semantic_kernel.agents.agent_base import AgentBase +from semantic_kernel.agents.agent import Agent from semantic_kernel.agents.agent_channel import AgentChannel from semantic_kernel.contents.chat_message_content import ChatMessageContent from semantic_kernel.contents.utils.author_role import AuthorRole @@ -15,7 +15,7 @@ class MockAgentChannel(AgentChannel): async def receive(self, history: list[ChatMessageContent]) -> None: pass - async def invoke(self, agent: "AgentBase") -> AsyncIterable[ChatMessageContent]: + async def invoke(self, agent: "Agent") -> AsyncIterable[ChatMessageContent]: yield ChatMessageContent(role=AuthorRole.SYSTEM, content="test message") async def get_history(self) -> AsyncIterable[ChatMessageContent]: diff --git a/python/tests/unit/agents/test_chat_completion_agent.py b/python/tests/unit/agents/test_chat_completion_agent.py index fe84b78d2a92..756b9b38e143 100644 --- a/python/tests/unit/agents/test_chat_completion_agent.py +++ b/python/tests/unit/agents/test_chat_completion_agent.py @@ -111,7 +111,7 @@ async def test_invoke_tool_call_added(): async def mock_get_chat_message_contents(chat_history, settings, kernel, arguments): new_messages = [ - ChatMessageContent(role=AuthorRole.SYSTEM, content="Processed Message 1"), + ChatMessageContent(role=AuthorRole.ASSISTANT, content="Processed Message 1"), ChatMessageContent(role=AuthorRole.TOOL, content="Processed Message 2"), ] chat_history.messages.extend(new_messages) From 2d2c5a544653fa91f055d6730fe60d5c8dc57287 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 11 Jul 2024 10:15:02 -0400 Subject: [PATCH 14/17] Restructure agent classes to make it cleaner. --- python/semantic_kernel/agents/agent.py | 21 +++--- .../semantic_kernel/agents/agent_channel.py | 8 +- .../agents/chat_completion_agent.py | 10 ++- .../agents/chat_history_channel.py | 32 ++++++-- .../agents/chat_history_handler.py | 33 --------- .../agents/chat_history_kernel_agent.py | 69 ----------------- .../unit/agents/test_chat_history_channel.py | 3 +- .../agents/test_chat_history_kernel_agent.py | 74 ------------------- 8 files changed, 49 insertions(+), 201 deletions(-) delete mode 100644 python/semantic_kernel/agents/chat_history_handler.py delete mode 100644 python/semantic_kernel/agents/chat_history_kernel_agent.py delete mode 100644 python/tests/unit/agents/test_chat_history_kernel_agent.py diff --git a/python/semantic_kernel/agents/agent.py b/python/semantic_kernel/agents/agent.py index 074fbbb72195..73ffcba0240e 100644 --- a/python/semantic_kernel/agents/agent.py +++ b/python/semantic_kernel/agents/agent.py @@ -1,18 +1,16 @@ # Copyright (c) Microsoft. All rights reserved. import uuid -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from abc import ABC +from typing import ClassVar from pydantic import Field +from semantic_kernel.agents.agent_channel import AgentChannel from semantic_kernel.kernel import Kernel from semantic_kernel.kernel_pydantic import KernelBaseModel from semantic_kernel.utils.experimental_decorator import experimental_class -if TYPE_CHECKING: - from semantic_kernel.agents.agent_channel import AgentChannel - @experimental_class class Agent(ABC, KernelBaseModel): @@ -36,21 +34,24 @@ class Agent(ABC, KernelBaseModel): name: str | None = None instructions: str | None = None kernel: Kernel = Field(default_factory=Kernel) + channel_type: ClassVar[type[AgentChannel] | None] = None - @abstractmethod def get_channel_keys(self) -> list[str]: """Get the channel keys. Returns: A list of channel keys. """ - ... + if not self.channel_type: + raise NotImplementedError("Unable to get channel keys. Channel type not configured.") + return [self.channel_type.__name__] - @abstractmethod - async def create_channel(self) -> "AgentChannel": + def create_channel(self) -> AgentChannel: """Create a channel. Returns: An instance of AgentChannel. """ - ... + if not self.channel_type: + raise NotImplementedError("Unable to create channel. Channel type not configured.") + return self.channel_type() diff --git a/python/semantic_kernel/agents/agent_channel.py b/python/semantic_kernel/agents/agent_channel.py index 1b288f678a23..ea834950e88e 100644 --- a/python/semantic_kernel/agents/agent_channel.py +++ b/python/semantic_kernel/agents/agent_channel.py @@ -4,8 +4,6 @@ from collections.abc import AsyncIterable from typing import TYPE_CHECKING -from pydantic import BaseModel - from semantic_kernel.utils.experimental_decorator import experimental_class if TYPE_CHECKING: @@ -14,7 +12,7 @@ @experimental_class -class AgentChannel(ABC, BaseModel): +class AgentChannel(ABC): """Defines the communication protocol for a particular Agent type. An agent provides it own AgentChannel via CreateChannel. @@ -35,7 +33,7 @@ async def receive( ... @abstractmethod - async def invoke( + def invoke( self, agent: "Agent", ) -> AsyncIterable["ChatMessageContent"]: @@ -50,7 +48,7 @@ async def invoke( ... @abstractmethod - async def get_history( + def get_history( self, ) -> AsyncIterable["ChatMessageContent"]: """Retrieve the message history specific to this channel. diff --git a/python/semantic_kernel/agents/chat_completion_agent.py b/python/semantic_kernel/agents/chat_completion_agent.py index b4b1c4270ee4..60a2d1f63054 100644 --- a/python/semantic_kernel/agents/chat_completion_agent.py +++ b/python/semantic_kernel/agents/chat_completion_agent.py @@ -3,14 +3,17 @@ import logging import sys from collections.abc import AsyncGenerator, AsyncIterable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar + +from semantic_kernel.agents.agent import Agent +from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel 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.chat_history_kernel_agent import ChatHistoryKernelAgent +from semantic_kernel.agents.agent_channel import AgentChannel from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings from semantic_kernel.const import DEFAULT_SERVICE_NAME @@ -29,7 +32,7 @@ @experimental_class -class ChatCompletionAgent(ChatHistoryKernelAgent): +class ChatCompletionAgent(Agent): """A KernelAgent specialization based on ChatCompletionClientBase. Note: enable `function_choice_behavior` on the PromptExecutionSettings to enable function @@ -39,6 +42,7 @@ class ChatCompletionAgent(ChatHistoryKernelAgent): service_id: str execution_settings: PromptExecutionSettings | None = None + channel_type: ClassVar[type[AgentChannel]] = ChatHistoryChannel def __init__( self, diff --git a/python/semantic_kernel/agents/chat_history_channel.py b/python/semantic_kernel/agents/chat_history_channel.py index de769dc09beb..956d73c32fe9 100644 --- a/python/semantic_kernel/agents/chat_history_channel.py +++ b/python/semantic_kernel/agents/chat_history_channel.py @@ -8,21 +8,44 @@ else: from typing_extensions import override # pragma: no cover +from abc import abstractmethod +from typing import TYPE_CHECKING, Protocol, runtime_checkable + from semantic_kernel.agents.agent import Agent from semantic_kernel.agents.agent_channel import AgentChannel -from semantic_kernel.agents.chat_history_handler import ChatHistoryHandler from semantic_kernel.contents import ChatMessageContent from semantic_kernel.contents.chat_history import ChatHistory from semantic_kernel.exceptions import ServiceInvalidTypeError from semantic_kernel.utils.experimental_decorator import experimental_class +if TYPE_CHECKING: + 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 + + +@experimental_class +@runtime_checkable +class ChatHistoryAgentProtocol(Protocol): + """Contract for an agent that utilizes a ChatHistoryChannel.""" + + @abstractmethod + def invoke(self, history: "ChatHistory") -> AsyncIterable["ChatMessageContent"]: + """Invoke the chat history agent protocol.""" + ... + + @abstractmethod + def invoke_stream(self, history: "ChatHistory") -> AsyncIterable["StreamingChatMessageContent"]: + """Invoke the chat history agent protocol in streaming mode.""" + ... + @experimental_class class ChatHistoryChannel(AgentChannel, ChatHistory): """An AgentChannel specialization for that acts upon a ChatHistoryHandler.""" @override - async def invoke( # type: ignore + async def invoke( self, agent: Agent, ) -> AsyncIterable[ChatMessageContent]: @@ -34,14 +57,13 @@ async def invoke( # type: ignore Returns: An async iterable of ChatMessageContent. """ - if not isinstance(agent, ChatHistoryHandler): + if not isinstance(agent, ChatHistoryAgentProtocol): id = agent.id if hasattr(agent, "id") else "" raise ServiceInvalidTypeError( f"Invalid channel binding for agent with id: `{id}` with name: ({type(agent).__name__})" ) - # Type checker does not recognize the async for loop - async for message in agent.invoke(self): # type: ignore + async for message in agent.invoke(self): self.messages.append(message) yield message diff --git a/python/semantic_kernel/agents/chat_history_handler.py b/python/semantic_kernel/agents/chat_history_handler.py deleted file mode 100644 index 95b06d3a195a..000000000000 --- a/python/semantic_kernel/agents/chat_history_handler.py +++ /dev/null @@ -1,33 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from abc import ABC, abstractmethod -from collections.abc import AsyncIterable -from typing import TYPE_CHECKING - -from semantic_kernel.utils.experimental_decorator import experimental_class - -if TYPE_CHECKING: - 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 - - -@experimental_class -class ChatHistoryHandler(ABC): - """Contract for an agent that utilizes a ChatHistoryChannel.""" - - @abstractmethod - async def invoke(self, history: "ChatHistory") -> AsyncIterable["ChatMessageContent"]: - """Invoke the chat history handler. - - Entry point for calling into an agent from a ChatHistoryChannel - """ - ... - - @abstractmethod - async def invoke_stream(self, history: "ChatHistory") -> AsyncIterable["StreamingChatMessageContent"]: - """Invoke the chat history handler in streaming mode. - - Entry point for calling into an agent from a ChatHistoryChannel for streaming content. - """ - ... diff --git a/python/semantic_kernel/agents/chat_history_kernel_agent.py b/python/semantic_kernel/agents/chat_history_kernel_agent.py deleted file mode 100644 index 3bf328b35697..000000000000 --- a/python/semantic_kernel/agents/chat_history_kernel_agent.py +++ /dev/null @@ -1,69 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import logging -import sys -from abc import ABC, abstractmethod -from collections.abc import AsyncIterable - -if sys.version_info >= (3, 12): - from typing import override -else: - from typing_extensions import override # pragma: no cover - -from semantic_kernel.agents.agent import Agent -from semantic_kernel.agents.agent_channel import AgentChannel -from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel -from semantic_kernel.agents.chat_history_handler import ChatHistoryHandler -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.utils.experimental_decorator import experimental_class - -logger: logging.Logger = logging.getLogger(__name__) - - -@experimental_class -class ChatHistoryKernelAgent(Agent, ChatHistoryHandler, ABC): - """A KernelAgent specialization bound to a ChatHistoryChannel.""" - - @override - def get_channel_keys(self) -> list[str]: - """Get the channel keys. - - Returns: - A list of channel keys. - """ - return [ChatHistoryChannel.__name__] - - @override - def create_channel(self) -> AgentChannel: # type: ignore - """Create a channel. - - Returns: - An instance of AgentChannel. - """ - return ChatHistoryChannel(messages=[]) - - @abstractmethod - async def invoke(self, history: ChatHistory) -> AsyncIterable[ChatMessageContent]: - """Invoke the chat history handler. - - Args: - history: The chat history. - - Returns: - An async iterable of ChatMessageContent. - """ - ... - - @abstractmethod - async def invoke_stream(self, history: ChatHistory) -> AsyncIterable[StreamingChatMessageContent]: - """Invoke the chat history handler in streaming mode. - - Args: - history: The chat history. - - Returns: - An async iterable of StreamingChatMessageContent. - """ - ... diff --git a/python/tests/unit/agents/test_chat_history_channel.py b/python/tests/unit/agents/test_chat_history_channel.py index 49d374a37894..e267f853ab5a 100644 --- a/python/tests/unit/agents/test_chat_history_channel.py +++ b/python/tests/unit/agents/test_chat_history_channel.py @@ -5,14 +5,13 @@ import pytest from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel -from semantic_kernel.agents.chat_history_handler import ChatHistoryHandler 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 import ServiceInvalidTypeError -class MockChatHistoryHandler(ChatHistoryHandler): +class MockChatHistoryHandler: """Mock agent to test chat history handling""" async def invoke(self, history: list[ChatMessageContent]) -> AsyncIterable[ChatMessageContent]: diff --git a/python/tests/unit/agents/test_chat_history_kernel_agent.py b/python/tests/unit/agents/test_chat_history_kernel_agent.py deleted file mode 100644 index b7504ff4d674..000000000000 --- a/python/tests/unit/agents/test_chat_history_kernel_agent.py +++ /dev/null @@ -1,74 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import uuid -from collections.abc import AsyncIterable - -from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel -from semantic_kernel.agents.chat_history_kernel_agent import ChatHistoryKernelAgent -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 - - -class MockChatHistoryKernelAgent(ChatHistoryKernelAgent): - """A mock ChatHistoryKernelAgent for testing purposes.""" - - def __init__( - self, - service_id: str = "test_service", - name: str = "Test Agent", - instructions: str = "Test Instructions", - id: str = None, - description: str = "Test Description", - ): - args = { - "service_id": service_id, - "name": name, - "instructions": instructions, - "description": description, - } - if id is not None: - args["id"] = id - super().__init__(**args) - - async def invoke(self, history: list[ChatMessageContent]) -> AsyncIterable[ChatMessageContent]: - for message in history: - yield ChatMessageContent(role=AuthorRole.SYSTEM, content=f"Processed: {message.content}") - - async def invoke_stream(self, history: list[ChatMessageContent]) -> AsyncIterable[StreamingChatMessageContent]: - pass - - -def test_initialization(): - name = "Test Agent" - instructions = "These are the instructions" - id_value = str(uuid.uuid4()) - description = "This is a test agent" - - agent = MockChatHistoryKernelAgent(name=name, instructions=instructions, id=id_value, description=description) - - assert agent.name == name - assert agent.instructions == instructions - assert agent.id == id_value - assert agent.description == description - - -def test_default_id(): - agent = MockChatHistoryKernelAgent() - - assert agent.id is not None - assert isinstance(uuid.UUID(agent.id), uuid.UUID) - - -def test_get_channel_keys(): - agent = MockChatHistoryKernelAgent() - keys = agent.get_channel_keys() - - assert keys == [ChatHistoryChannel.__name__] - - -def test_create_channel(): - agent = MockChatHistoryKernelAgent() - channel = agent.create_channel() - - assert isinstance(channel, ChatHistoryChannel) From 8fd6af756d3df1ff5f57f7b225c9aae1aaa9a72f Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 11 Jul 2024 11:34:50 -0400 Subject: [PATCH 15/17] Fix test invoke for chat history channel for Windows platform --- python/tests/unit/agents/test_chat_history_channel.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/python/tests/unit/agents/test_chat_history_channel.py b/python/tests/unit/agents/test_chat_history_channel.py index e267f853ab5a..b3160cb91ebf 100644 --- a/python/tests/unit/agents/test_chat_history_channel.py +++ b/python/tests/unit/agents/test_chat_history_channel.py @@ -4,7 +4,7 @@ import pytest -from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel +from semantic_kernel.agents.chat_history_channel import ChatHistoryAgentProtocol, ChatHistoryChannel 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 @@ -28,6 +28,9 @@ class MockNonChatHistoryHandler: id: str = "mock_non_chat_history_handler" +ChatHistoryAgentProtocol.register(MockChatHistoryHandler) + + @pytest.mark.asyncio async def test_invoke(): channel = ChatHistoryChannel() From 255b4566d0b9d8616054a2e12cb3e22b015a11f3 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 11 Jul 2024 15:00:21 -0400 Subject: [PATCH 16/17] Adjust how we add the chat content --- python/samples/concepts/agents/step1_agent.py | 6 +++++- python/samples/concepts/agents/step2_plugins.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/python/samples/concepts/agents/step1_agent.py b/python/samples/concepts/agents/step1_agent.py index 586c3ebf317c..08e6fdeda8f0 100644 --- a/python/samples/concepts/agents/step1_agent.py +++ b/python/samples/concepts/agents/step1_agent.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +from functools import reduce from semantic_kernel.agents.chat_completion_agent import ChatCompletionAgent from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion @@ -34,10 +35,13 @@ async def invoke_agent(agent: ChatCompletionAgent, input: str, chat: ChatHistory async for content in agent.invoke_stream(chat): content_name = content.name contents.append(content) - print(f"# {content.role} - {content_name or '*'}: '{''.join([content.content for content in contents])}'") + streaming_chat_message = reduce(lambda first, second: first + second, contents) + print(f"# {content.role} - {content_name or '*'}: '{streaming_chat_message}'") + chat.add_message(content) else: async for content in agent.invoke(chat): print(f"# {content.role} - {content.name or '*'}: '{content.content}'") + chat.add_message(content) async def main(): diff --git a/python/samples/concepts/agents/step2_plugins.py b/python/samples/concepts/agents/step2_plugins.py index 6abdc2afba10..46111da6100a 100644 --- a/python/samples/concepts/agents/step2_plugins.py +++ b/python/samples/concepts/agents/step2_plugins.py @@ -63,7 +63,7 @@ async def invoke_agent(agent: ChatCompletionAgent, input: str, chat: ChatHistory else: async for content in agent.invoke(chat): print(f"# {content.role} - {content.name or '*'}: '{content.content}'") - chat.add_assistant_message(content.content) + chat.add_message(content) async def main(): From 8ed486c1746916b4d18f3370cb9cae0b03bc4004 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 11 Jul 2024 15:49:56 -0400 Subject: [PATCH 17/17] Cleaning up code --- .../agents/chat_completion_agent.py | 24 ++++--------------- .../agents/chat_history_channel.py | 2 +- .../unit/agents/test_chat_completion_agent.py | 4 ++-- 3 files changed, 8 insertions(+), 22 deletions(-) diff --git a/python/semantic_kernel/agents/chat_completion_agent.py b/python/semantic_kernel/agents/chat_completion_agent.py index 60a2d1f63054..44cf48f94722 100644 --- a/python/semantic_kernel/agents/chat_completion_agent.py +++ b/python/semantic_kernel/agents/chat_completion_agent.py @@ -1,19 +1,12 @@ # Copyright (c) Microsoft. All rights reserved. import logging -import sys from collections.abc import AsyncGenerator, AsyncIterable from typing import TYPE_CHECKING, Any, ClassVar from semantic_kernel.agents.agent import Agent -from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel - -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_channel import AgentChannel +from semantic_kernel.agents.chat_history_channel import ChatHistoryChannel from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings from semantic_kernel.const import DEFAULT_SERVICE_NAME @@ -22,7 +15,6 @@ 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.functions.kernel_arguments import KernelArguments from semantic_kernel.utils.experimental_decorator import experimental_class if TYPE_CHECKING: @@ -70,7 +62,7 @@ def __init__( if not service_id: service_id = DEFAULT_SERVICE_NAME - args = { + args: dict[str, Any] = { "service_id": service_id, "name": name, "description": description, @@ -80,11 +72,10 @@ def __init__( if id is not None: args["id"] = id if kernel is not None: - args["kernel"] = kernel # type: ignore + args["kernel"] = kernel super().__init__(**args) - @override - async def invoke(self, history: ChatHistory) -> AsyncIterable[ChatMessageContent]: # type: ignore + async def invoke(self, history: ChatHistory) -> AsyncIterable[ChatMessageContent]: """Invoke the chat history handler. Args: @@ -120,7 +111,6 @@ async def invoke(self, history: ChatHistory) -> AsyncIterable[ChatMessageContent chat_history=chat, settings=settings, kernel=self.kernel, - arguments=KernelArguments(), ) logger.info( @@ -138,10 +128,7 @@ async def invoke(self, history: ChatHistory) -> AsyncIterable[ChatMessageContent message.name = self.name yield message - @override - async def invoke_stream( # type: ignore - self, history: ChatHistory - ) -> AsyncIterable[StreamingChatMessageContent]: + async def invoke_stream(self, history: ChatHistory) -> AsyncIterable[StreamingChatMessageContent]: """Invoke the chat history handler in streaming mode. Args: @@ -178,7 +165,6 @@ async def invoke_stream( # type: ignore chat_history=chat, settings=settings, kernel=self.kernel, - arguments=KernelArguments(), ) ) diff --git a/python/semantic_kernel/agents/chat_history_channel.py b/python/semantic_kernel/agents/chat_history_channel.py index 956d73c32fe9..dc4a1b231b1d 100644 --- a/python/semantic_kernel/agents/chat_history_channel.py +++ b/python/semantic_kernel/agents/chat_history_channel.py @@ -58,7 +58,7 @@ async def invoke( An async iterable of ChatMessageContent. """ if not isinstance(agent, ChatHistoryAgentProtocol): - id = agent.id if hasattr(agent, "id") else "" + id = getattr(agent, "id", "") raise ServiceInvalidTypeError( f"Invalid channel binding for agent with id: `{id}` with name: ({type(agent).__name__})" ) diff --git a/python/tests/unit/agents/test_chat_completion_agent.py b/python/tests/unit/agents/test_chat_completion_agent.py index 756b9b38e143..7b40176cbfd1 100644 --- a/python/tests/unit/agents/test_chat_completion_agent.py +++ b/python/tests/unit/agents/test_chat_completion_agent.py @@ -18,7 +18,7 @@ def mock_streaming_chat_completion_response() -> AsyncMock: """A fixture that returns a mock response for a streaming chat completion response.""" - async def mock_response(chat_history, settings, kernel, arguments): + async def mock_response(chat_history, settings, kernel): content1 = ChatMessageContent(role=AuthorRole.SYSTEM, content="Processed Message 1") content2 = ChatMessageContent(role=AuthorRole.TOOL, content="Processed Message 2") chat_history.messages.append(content1) @@ -109,7 +109,7 @@ async def test_invoke_tool_call_added(): history = ChatHistory(messages=[ChatMessageContent(role=AuthorRole.USER, content="Initial Message")]) - async def mock_get_chat_message_contents(chat_history, settings, kernel, arguments): + async def mock_get_chat_message_contents(chat_history, settings, kernel): new_messages = [ ChatMessageContent(role=AuthorRole.ASSISTANT, content="Processed Message 1"), ChatMessageContent(role=AuthorRole.TOOL, content="Processed Message 2"),