Skip to content

Commit 3a5d601

Browse files
authored
Python: Fix agent group chat bug related to function calling in ChatCompletionAgent (#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 😄
1 parent 6680b2e commit 3a5d601

File tree

4 files changed

+128
-4
lines changed

4 files changed

+128
-4
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
import asyncio
4+
from typing import Annotated
5+
6+
from semantic_kernel.agents import AgentGroupChat, ChatCompletionAgent
7+
from semantic_kernel.agents.open_ai import OpenAIAssistantAgent
8+
from semantic_kernel.agents.strategies.termination.termination_strategy import TerminationStrategy
9+
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
10+
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
11+
from semantic_kernel.contents.chat_message_content import ChatMessageContent
12+
from semantic_kernel.contents.utils.author_role import AuthorRole
13+
from semantic_kernel.functions.kernel_function_decorator import kernel_function
14+
from semantic_kernel.kernel import Kernel
15+
16+
#####################################################################
17+
# The following sample demonstrates how to create an OpenAI #
18+
# assistant using either Azure OpenAI or OpenAI, a chat completion #
19+
# agent and have them participate in a group chat to work towards #
20+
# the user's requirement. The ChatCompletionAgent uses a plugin #
21+
# that is part of the agent group chat. #
22+
#####################################################################
23+
24+
25+
class ApprovalTerminationStrategy(TerminationStrategy):
26+
"""A strategy for determining when an agent should terminate."""
27+
28+
async def should_agent_terminate(self, agent, history):
29+
"""Check if the agent should terminate."""
30+
return "approved" in history[-1].content.lower()
31+
32+
33+
REVIEWER_NAME = "ArtDirector"
34+
REVIEWER_INSTRUCTIONS = """
35+
You are an art director who has opinions about copywriting born of a love for David Ogilvy.
36+
The goal is to determine if the given copy is acceptable to print.
37+
If so, state that it is approved. Only include the word "approved" if it is so.
38+
If not, provide insight on how to refine suggested copy without example.
39+
You should always tie the conversation back to the food specials offered by the plugin.
40+
"""
41+
42+
COPYWRITER_NAME = "CopyWriter"
43+
COPYWRITER_INSTRUCTIONS = """
44+
You are a copywriter with ten years of experience and are known for brevity and a dry humor.
45+
The goal is to refine and decide on the single best copy as an expert in the field.
46+
Only provide a single proposal per response.
47+
You're laser focused on the goal at hand.
48+
Don't waste time with chit chat.
49+
Consider suggestions when refining an idea.
50+
"""
51+
52+
53+
class MenuPlugin:
54+
"""A sample Menu Plugin used for the concept sample."""
55+
56+
@kernel_function(description="Provides a list of specials from the menu.")
57+
def get_specials(self) -> Annotated[str, "Returns the specials from the menu."]:
58+
return """
59+
Special Soup: Clam Chowder
60+
Special Salad: Cobb Salad
61+
Special Drink: Chai Tea
62+
"""
63+
64+
@kernel_function(description="Provides the price of the requested menu item.")
65+
def get_item_price(
66+
self, menu_item: Annotated[str, "The name of the menu item."]
67+
) -> Annotated[str, "Returns the price of the menu item."]:
68+
return "$9.99"
69+
70+
71+
def _create_kernel_with_chat_completion(service_id: str) -> Kernel:
72+
kernel = Kernel()
73+
kernel.add_service(AzureChatCompletion(service_id=service_id))
74+
kernel.add_plugin(plugin=MenuPlugin(), plugin_name="menu")
75+
return kernel
76+
77+
78+
async def main():
79+
try:
80+
kernel = _create_kernel_with_chat_completion("artdirector")
81+
settings = kernel.get_prompt_execution_settings_from_service_id(service_id="artdirector")
82+
# Configure the function choice behavior to auto invoke kernel functions
83+
settings.function_choice_behavior = FunctionChoiceBehavior.Auto()
84+
agent_reviewer = ChatCompletionAgent(
85+
service_id="artdirector",
86+
kernel=kernel,
87+
name=REVIEWER_NAME,
88+
instructions=REVIEWER_INSTRUCTIONS,
89+
execution_settings=settings,
90+
)
91+
92+
agent_writer = await OpenAIAssistantAgent.create(
93+
service_id="copywriter",
94+
kernel=Kernel(),
95+
name=COPYWRITER_NAME,
96+
instructions=COPYWRITER_INSTRUCTIONS,
97+
)
98+
99+
chat = AgentGroupChat(
100+
agents=[agent_writer, agent_reviewer],
101+
termination_strategy=ApprovalTerminationStrategy(agents=[agent_reviewer], maximum_iterations=10),
102+
)
103+
104+
input = "Write copy based on the food specials."
105+
106+
await chat.add_chat_message(ChatMessageContent(role=AuthorRole.USER, content=input))
107+
print(f"# {AuthorRole.USER}: '{input}'")
108+
109+
async for content in chat.invoke():
110+
print(f"# {content.role} - {content.name or '*'}: '{content.content}'")
111+
112+
print(f"# IS COMPLETE: {chat.is_complete}")
113+
finally:
114+
await agent_writer.delete()
115+
116+
117+
if __name__ == "__main__":
118+
asyncio.run(main())

python/semantic_kernel/agents/channels/open_ai_assistant_channel.py

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from collections.abc import AsyncIterable
55
from typing import TYPE_CHECKING, Any
66

7+
from semantic_kernel.contents.function_call_content import FunctionCallContent
8+
79
if sys.version_info >= (3, 12):
810
from typing import override # pragma: no cover
911
else:
@@ -36,6 +38,8 @@ async def receive(self, history: list["ChatMessageContent"]) -> None:
3638
history: The conversation messages.
3739
"""
3840
for message in history:
41+
if any(isinstance(item, FunctionCallContent) for item in message.items):
42+
continue
3943
await create_chat_message(self.client, self.thread_id, message)
4044

4145
@override

python/semantic_kernel/agents/open_ai/assistant_content_generation.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ async def create_chat_message(
4747
Returns:
4848
Message: The message.
4949
"""
50-
if message.role.value not in allowed_message_roles:
50+
if message.role.value not in allowed_message_roles and message.role != AuthorRole.TOOL:
5151
raise AgentExecutionException(
5252
f"Invalid message role `{message.role.value}`. Allowed roles are {allowed_message_roles}."
5353
)
@@ -56,7 +56,7 @@ async def create_chat_message(
5656

5757
return await client.beta.threads.messages.create(
5858
thread_id=thread_id,
59-
role=message.role.value, # type: ignore
59+
role="assistant" if message.role == AuthorRole.TOOL else message.role.value, # type: ignore
6060
content=message_contents, # type: ignore
6161
)
6262

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

8385

python/tests/unit/agents/test_open_ai_assistant_base.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -810,9 +810,9 @@ async def test_add_chat_message(
810810
async def test_add_chat_message_invalid_role(
811811
azure_openai_assistant_agent, mock_chat_message_content, openai_unit_test_env
812812
):
813-
mock_chat_message_content.role = AuthorRole.TOOL
813+
mock_chat_message_content.role = AuthorRole.SYSTEM
814814

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

818818

0 commit comments

Comments
 (0)