Skip to content

Commit

Permalink
Python: Fix agent group chat bug related to function calling in ChatC…
Browse files Browse the repository at this point in the history
…ompletionAgent (#8330)

### Motivation and Context

During a group chat, if using an OpenAIAssistantAgent, the addition of a
ChatCompletionMessage to the AssistantAgent channel was breaking because
we were including FunctionCallContent. Additionally, there needs to be
some translation of a FunctionResultContent type to a text type when
adding a ChatCompletionAgent message to an AssistantAgent thread
message. The AuthorRole can only be 'user' or 'assistant'.

<!-- Thank you for your contribution to the semantic-kernel repo!
Please help reviewers and future users, providing the following
information:
  1. Why is this change required?
  2. What problem does it solve?
  3. What scenario does it contribute to?
  4. If it fixes an open issue, please link to the issue here.
-->

### Description

Fixes for the items mentioned above. This may not be a perfect solution,
but getting this in and I can revisit it to align more closely with how
dotnet handles the FunctionResultContent/TextContent as part of the
overall ChatMessageContent.
- This PR also adds a new sample showing mixed agent chat and a
ChatCompletionAgent using plugins.

<!-- Describe your changes, the overall approach, the underlying design.
These notes will help understanding how your code works. Thanks! -->

### Contribution Checklist

<!-- Before submitting this PR, please make sure: -->

- [X] The code builds clean without any errors or warnings
- [X] The PR follows the [SK Contribution
Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md)
and the [pre-submission formatting
script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts)
raises no violations
- [X] All unit tests pass, and I have added new tests where possible
- [X] I didn't break anyone 😄
  • Loading branch information
moonbox3 authored Aug 22, 2024
1 parent 6680b2e commit 3a5d601
Show file tree
Hide file tree
Showing 4 changed files with 128 additions and 4 deletions.
118 changes: 118 additions & 0 deletions python/samples/concepts/agents/mixed_chat_agents_plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Copyright (c) Microsoft. All rights reserved.

import asyncio
from typing import Annotated

from semantic_kernel.agents import AgentGroupChat, ChatCompletionAgent
from semantic_kernel.agents.open_ai import OpenAIAssistantAgent
from semantic_kernel.agents.strategies.termination.termination_strategy import TerminationStrategy
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
from semantic_kernel.contents.chat_message_content import ChatMessageContent
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 an OpenAI #
# assistant using either Azure OpenAI or OpenAI, a chat completion #
# agent and have them participate in a group chat to work towards #
# the user's requirement. The ChatCompletionAgent uses a plugin #
# that is part of the agent group chat. #
#####################################################################


class ApprovalTerminationStrategy(TerminationStrategy):
"""A strategy for determining when an agent should terminate."""

async def should_agent_terminate(self, agent, history):
"""Check if the agent should terminate."""
return "approved" in history[-1].content.lower()


REVIEWER_NAME = "ArtDirector"
REVIEWER_INSTRUCTIONS = """
You are an art director who has opinions about copywriting born of a love for David Ogilvy.
The goal is to determine if the given copy is acceptable to print.
If so, state that it is approved. Only include the word "approved" if it is so.
If not, provide insight on how to refine suggested copy without example.
You should always tie the conversation back to the food specials offered by the plugin.
"""

COPYWRITER_NAME = "CopyWriter"
COPYWRITER_INSTRUCTIONS = """
You are a copywriter with ten years of experience and are known for brevity and a dry humor.
The goal is to refine and decide on the single best copy as an expert in the field.
Only provide a single proposal per response.
You're laser focused on the goal at hand.
Don't waste time with chit chat.
Consider suggestions when refining an idea.
"""


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"


def _create_kernel_with_chat_completion(service_id: str) -> Kernel:
kernel = Kernel()
kernel.add_service(AzureChatCompletion(service_id=service_id))
kernel.add_plugin(plugin=MenuPlugin(), plugin_name="menu")
return kernel


async def main():
try:
kernel = _create_kernel_with_chat_completion("artdirector")
settings = kernel.get_prompt_execution_settings_from_service_id(service_id="artdirector")
# Configure the function choice behavior to auto invoke kernel functions
settings.function_choice_behavior = FunctionChoiceBehavior.Auto()
agent_reviewer = ChatCompletionAgent(
service_id="artdirector",
kernel=kernel,
name=REVIEWER_NAME,
instructions=REVIEWER_INSTRUCTIONS,
execution_settings=settings,
)

agent_writer = await OpenAIAssistantAgent.create(
service_id="copywriter",
kernel=Kernel(),
name=COPYWRITER_NAME,
instructions=COPYWRITER_INSTRUCTIONS,
)

chat = AgentGroupChat(
agents=[agent_writer, agent_reviewer],
termination_strategy=ApprovalTerminationStrategy(agents=[agent_reviewer], maximum_iterations=10),
)

input = "Write copy based on the food specials."

await chat.add_chat_message(ChatMessageContent(role=AuthorRole.USER, content=input))
print(f"# {AuthorRole.USER}: '{input}'")

async for content in chat.invoke():
print(f"# {content.role} - {content.name or '*'}: '{content.content}'")

print(f"# IS COMPLETE: {chat.is_complete}")
finally:
await agent_writer.delete()


if __name__ == "__main__":
asyncio.run(main())
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from collections.abc import AsyncIterable
from typing import TYPE_CHECKING, Any

from semantic_kernel.contents.function_call_content import FunctionCallContent

if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
Expand Down Expand Up @@ -36,6 +38,8 @@ async def receive(self, history: list["ChatMessageContent"]) -> None:
history: The conversation messages.
"""
for message in history:
if any(isinstance(item, FunctionCallContent) for item in message.items):
continue
await create_chat_message(self.client, self.thread_id, message)

@override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ async def create_chat_message(
Returns:
Message: The message.
"""
if message.role.value not in allowed_message_roles:
if message.role.value not in allowed_message_roles and message.role != AuthorRole.TOOL:
raise AgentExecutionException(
f"Invalid message role `{message.role.value}`. Allowed roles are {allowed_message_roles}."
)
Expand All @@ -56,7 +56,7 @@ async def create_chat_message(

return await client.beta.threads.messages.create(
thread_id=thread_id,
role=message.role.value, # type: ignore
role="assistant" if message.role == AuthorRole.TOOL else message.role.value, # type: ignore
content=message_contents, # type: ignore
)

Expand All @@ -78,6 +78,8 @@ def get_message_contents(message: "ChatMessageContent") -> list[dict[str, Any]]:
"type": "image_file",
"image_file": {"file_id": content.file_id},
})
elif isinstance(content, FunctionResultContent):
contents.append({"type": "text", "text": content.result})
return contents


Expand Down
4 changes: 2 additions & 2 deletions python/tests/unit/agents/test_open_ai_assistant_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -810,9 +810,9 @@ async def test_add_chat_message(
async def test_add_chat_message_invalid_role(
azure_openai_assistant_agent, mock_chat_message_content, openai_unit_test_env
):
mock_chat_message_content.role = AuthorRole.TOOL
mock_chat_message_content.role = AuthorRole.SYSTEM

with pytest.raises(AgentExecutionException, match="Invalid message role `tool`"):
with pytest.raises(AgentExecutionException, match="Invalid message role `system`"):
await azure_openai_assistant_agent.add_chat_message("test_thread_id", mock_chat_message_content)


Expand Down

0 comments on commit 3a5d601

Please sign in to comment.