diff --git a/python/samples/learn_resources/agent_docs/agent_collaboration.py b/python/samples/learn_resources/agent_docs/agent_collaboration.py index c8e7d04bffb6..a4086febe177 100644 --- a/python/samples/learn_resources/agent_docs/agent_collaboration.py +++ b/python/samples/learn_resources/agent_docs/agent_collaboration.py @@ -3,19 +3,16 @@ import asyncio import os +from semantic_kernel import Kernel from semantic_kernel.agents import AgentGroupChat, ChatCompletionAgent -from semantic_kernel.agents.strategies.selection.kernel_function_selection_strategy import ( +from semantic_kernel.agents.strategies import ( KernelFunctionSelectionStrategy, -) -from semantic_kernel.agents.strategies.termination.kernel_function_termination_strategy import ( KernelFunctionTerminationStrategy, ) -from semantic_kernel.agents.strategies.termination.termination_strategy import TerminationStrategy -from semantic_kernel.connectors.ai.open_ai.services.azure_chat_completion import AzureChatCompletion -from semantic_kernel.contents.chat_message_content import ChatMessageContent +from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion +from semantic_kernel.contents import ChatHistoryTruncationReducer, ChatMessageContent from semantic_kernel.contents.utils.author_role import AuthorRole from semantic_kernel.functions.kernel_function_from_prompt import KernelFunctionFromPrompt -from semantic_kernel.kernel import Kernel ################################################################### # The following sample demonstrates how to create a simple, # @@ -24,117 +21,123 @@ # complete a user's task. # ################################################################### - -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() - - +# Define agent names REVIEWER_NAME = "Reviewer" -COPYWRITER_NAME = "Writer" +WRITER_NAME = "Writer" -def _create_kernel_with_chat_completion(service_id: str) -> Kernel: +def create_kernel() -> Kernel: + """Creates a Kernel instance with an Azure OpenAI ChatCompletion service.""" kernel = Kernel() - kernel.add_service(AzureChatCompletion(service_id=service_id)) + kernel.add_service(service=AzureChatCompletion()) return kernel async def main(): + # Create a single kernel instance for all agents. + kernel = create_kernel() + + # Create ChatCompletionAgents using the same kernel. agent_reviewer = ChatCompletionAgent( service_id=REVIEWER_NAME, - kernel=_create_kernel_with_chat_completion(REVIEWER_NAME), + kernel=kernel, name=REVIEWER_NAME, instructions=""" - Your responsibility is to review and identify how to improve user provided content. - If the user has providing input or direction for content already provided, specify how to - address this input. - Never directly perform the correction or provide example. - Once the content has been updated in a subsequent response, you will review the content - again until satisfactory. - Always copy satisfactory content to the clipboard using available tools and inform user. - - RULES: - - Only identify suggestions that are specific and actionable. - - Verify previous suggestions have been addressed. - - Never repeat previous suggestions. - """, +Your responsibility is to review and identify how to improve user provided content. +If the user has provided input or direction for content already provided, specify how to address this input. +Never directly perform the correction or provide an example. +Once the content has been updated in a subsequent response, review it again until it is satisfactory. + +RULES: +- Only identify suggestions that are specific and actionable. +- Verify previous suggestions have been addressed. +- Never repeat previous suggestions. +""", ) agent_writer = ChatCompletionAgent( - service_id=COPYWRITER_NAME, - kernel=_create_kernel_with_chat_completion(COPYWRITER_NAME), - name=COPYWRITER_NAME, + service_id=WRITER_NAME, + kernel=kernel, + name=WRITER_NAME, instructions=""" - Your sole responsibility is to rewrite content according to review suggestions. - - - Always apply all review direction. - - Always revise the content in its entirety without explanation. - - Never address the user. - """, +Your sole responsibility is to rewrite content according to review suggestions. +- Always apply all review directions. +- Always revise the content in its entirety without explanation. +- Never address the user. +""", ) + # Define a selection function to determine which agent should take the next turn. selection_function = KernelFunctionFromPrompt( function_name="selection", prompt=f""" - Determine which participant takes the next turn in a conversation based on the the most recent participant. - State only the name of the participant to take the next turn. - No participant should take more than one turn in a row. - - Choose only from these participants: - - {REVIEWER_NAME} - - {COPYWRITER_NAME} - - Always follow these rules when selecting the next participant: - - After user input, it is {COPYWRITER_NAME}'s turn. - - After {COPYWRITER_NAME} replies, it is {REVIEWER_NAME}'s turn. - - After {REVIEWER_NAME} provides feedback, it is {COPYWRITER_NAME}'s turn. - - History: - {{{{$history}}}} - """, +Examine the provided RESPONSE and choose the next participant. +State only the name of the chosen participant without explanation. +Never choose the participant named in the RESPONSE. + +Choose only from these participants: +- {REVIEWER_NAME} +- {WRITER_NAME} + +Rules: +- If RESPONSE is user input, it is {REVIEWER_NAME}'s turn. +- If RESPONSE is by {REVIEWER_NAME}, it is {WRITER_NAME}'s turn. +- If RESPONSE is by {WRITER_NAME}, it is {REVIEWER_NAME}'s turn. + +RESPONSE: +{{{{$lastmessage}}}} +""", ) - TERMINATION_KEYWORD = "yes" + # Define a termination function where the reviewer signals completion with "yes". + termination_keyword = "yes" termination_function = KernelFunctionFromPrompt( function_name="termination", prompt=f""" - Examine the RESPONSE and determine whether the content has been deemed satisfactory. - If content is satisfactory, respond with a single word without explanation: {TERMINATION_KEYWORD}. - If specific suggestions are being provided, it is not satisfactory. - If no correction is suggested, it is satisfactory. - - RESPONSE: - {{{{$history}}}} - """, +Examine the RESPONSE and determine whether the content has been deemed satisfactory. +If the content is satisfactory, respond with a single word without explanation: {termination_keyword}. +If specific suggestions are being provided, it is not satisfactory. +If no correction is suggested, it is satisfactory. + +RESPONSE: +{{{{$lastmessage}}}} +""", ) + history_reducer = ChatHistoryTruncationReducer(target_count=5) + + # Create the AgentGroupChat with selection and termination strategies. chat = AgentGroupChat( - agents=[agent_writer, agent_reviewer], + agents=[agent_reviewer, agent_writer], selection_strategy=KernelFunctionSelectionStrategy( + initial_agent=agent_reviewer, function=selection_function, - kernel=_create_kernel_with_chat_completion("selection"), - result_parser=lambda result: str(result.value[0]) if result.value is not None else COPYWRITER_NAME, - agent_variable_name="agents", - history_variable_name="history", + kernel=kernel, + result_parser=lambda result: str(result.value[0]).strip() if result.value[0] is not None else WRITER_NAME, + history_variable_name="lastmessage", + history_reducer=history_reducer, ), termination_strategy=KernelFunctionTerminationStrategy( agents=[agent_reviewer], function=termination_function, - kernel=_create_kernel_with_chat_completion("termination"), - result_parser=lambda result: TERMINATION_KEYWORD in str(result.value[0]).lower(), - history_variable_name="history", + kernel=kernel, + result_parser=lambda result: termination_keyword in str(result.value[0]).lower(), + history_variable_name="lastmessage", maximum_iterations=10, + history_reducer=history_reducer, ), ) - is_complete: bool = False + print( + "Ready! Type your input, or 'exit' to quit, 'reset' to restart the conversation. " + "You may pass in a file path using @." + ) + + is_complete = False while not is_complete: - user_input = input("User:> ") + print() + user_input = input("User > ").strip() if not user_input: continue @@ -147,26 +150,35 @@ async def main(): print("[Conversation has been reset]") continue - if user_input.startswith("@") and len(input) > 1: - file_path = input[1:] + # Try to grab files from the script's current directory + if user_input.startswith("@") and len(user_input) > 1: + file_name = user_input[1:] + script_dir = os.path.dirname(os.path.abspath(__file__)) + file_path = os.path.join(script_dir, file_name) try: if not os.path.exists(file_path): print(f"Unable to access file: {file_path}") continue - with open(file_path) as file: + with open(file_path, encoding="utf-8") as file: user_input = file.read() except Exception: print(f"Unable to access file: {file_path}") continue + # Add the current user_input to the chat await chat.add_chat_message(ChatMessageContent(role=AuthorRole.USER, content=user_input)) - async for response in chat.invoke(): - print(f"# {response.role} - {response.name or '*'}: '{response.content}'") + try: + async for response in chat.invoke(): + if response is None or not response.name: + continue + print() + print(f"# {response.name.upper()}:\n{response.content}") + except Exception as e: + print(f"Error during chat invocation: {e}") - if chat.is_complete: - is_complete = True - break + # Reset the chat's complete flag for the new conversation round. + chat.is_complete = False if __name__ == "__main__": diff --git a/python/samples/learn_resources/agent_docs/assistant_code.py b/python/samples/learn_resources/agent_docs/assistant_code.py index da411ad7df62..9820fbd781da 100644 --- a/python/samples/learn_resources/agent_docs/assistant_code.py +++ b/python/samples/learn_resources/agent_docs/assistant_code.py @@ -4,10 +4,8 @@ import logging import os -from semantic_kernel.agents.open_ai.azure_assistant_agent import AzureAssistantAgent -from semantic_kernel.contents.chat_message_content import ChatMessageContent -from semantic_kernel.contents.streaming_file_reference_content import StreamingFileReferenceContent -from semantic_kernel.contents.utils.author_role import AuthorRole +from semantic_kernel.agents.open_ai import AzureAssistantAgent +from semantic_kernel.contents import AuthorRole, ChatMessageContent, StreamingFileReferenceContent from semantic_kernel.kernel import Kernel logging.basicConfig(level=logging.ERROR) diff --git a/python/samples/learn_resources/agent_docs/assistant_search.py b/python/samples/learn_resources/agent_docs/assistant_search.py index 5d91786e9bc4..7e08e179c04b 100644 --- a/python/samples/learn_resources/agent_docs/assistant_search.py +++ b/python/samples/learn_resources/agent_docs/assistant_search.py @@ -3,10 +3,8 @@ import asyncio import os -from semantic_kernel.agents.open_ai.azure_assistant_agent import AzureAssistantAgent -from semantic_kernel.contents.chat_message_content import ChatMessageContent -from semantic_kernel.contents.streaming_annotation_content import StreamingAnnotationContent -from semantic_kernel.contents.utils.author_role import AuthorRole +from semantic_kernel.agents.open_ai import AzureAssistantAgent +from semantic_kernel.contents import AuthorRole, ChatMessageContent, StreamingAnnotationContent from semantic_kernel.kernel import Kernel ################################################################### diff --git a/python/samples/learn_resources/agent_docs/chat_agent.py b/python/samples/learn_resources/agent_docs/chat_agent.py index 56340b3595b4..32c2b3d11376 100644 --- a/python/samples/learn_resources/agent_docs/chat_agent.py +++ b/python/samples/learn_resources/agent_docs/chat_agent.py @@ -8,9 +8,7 @@ from semantic_kernel.agents 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.chat_message_content import ChatMessageContent -from semantic_kernel.contents.utils.author_role import AuthorRole +from semantic_kernel.contents import AuthorRole, ChatHistory, ChatMessageContent from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.kernel import Kernel diff --git a/python/samples/learn_resources/resources/WomensSuffrage.txt b/python/samples/learn_resources/resources/WomensSuffrage.txt new file mode 100644 index 000000000000..3100274682f2 --- /dev/null +++ b/python/samples/learn_resources/resources/WomensSuffrage.txt @@ -0,0 +1,9 @@ +Women's suffrage is when women got the right to vote. A long time ago, only men could vote and make decisions. This was not fair because women should have the same rights as men. Women wanted to vote too, so they started asking for it. It took a long time, and they had to work very hard to make people listen to them. Many men did not think women should vote, and this made it very hard for the women. + +The women who fought for voting were called suffragets. They did many things to show they wanted the right to vote. Some gave speeches, others made signs and marched in the streets. Some even went to jail because they refused to stop fighting for what they believed was right. It was scary for some of the women, but they knew how important it was to keep trying. They wanted to change the world so that it was more fair for everyone. + +One of the most important suffragets was Susan B. Anthony. She worked very hard to help women get the right to vote. She gave speeches and wrote letters to the goverment to make them change the laws. Susan never gave up, even when people said mean things to her. Another important person was Elizabeth Cady Stanton. She also helped fight for women's rights and was friends with Susan B. Anthony. Together, they made a great team and helped make big changes. + +Finally, in 1920, the 19th amendment was passed in the United States. This law gave women the right to vote. It was a huge victory for the suffragets, and they were very happy. Many women went to vote for the first time, and it felt like they were finally equal with men. It took many years and a lot of hard work, but the women never gave up. They kept fighting until they won. + +Women's suffrage is very important because it shows that if you work hard and believe in something, you can make a change. The women who fought for the right to vote showed bravery and strengh, and they helped make the world a better place. Today, women can vote because of them, and it's important to remember their hard work. We should always stand up for what is right, just like the suffragets did. diff --git a/python/semantic_kernel/agents/azure_ai/agent_content_generation.py b/python/semantic_kernel/agents/azure_ai/agent_content_generation.py index 2d4f33d0903a..1cd47452d9f7 100644 --- a/python/semantic_kernel/agents/azure_ai/agent_content_generation.py +++ b/python/semantic_kernel/agents/azure_ai/agent_content_generation.py @@ -337,8 +337,8 @@ def generate_streaming_code_interpreter_content( metadata: dict[str, bool] = {} for index, tool in enumerate(step_details.tool_calls): - if isinstance(tool.type, RunStepDeltaCodeInterpreterToolCall): - code_interpreter_tool_call: RunStepDeltaCodeInterpreterDetailItemObject = tool + if isinstance(tool, RunStepDeltaCodeInterpreterDetailItemObject): + code_interpreter_tool_call = tool if code_interpreter_tool_call.input: items.append( StreamingTextContent( @@ -349,7 +349,11 @@ def generate_streaming_code_interpreter_content( metadata["code"] = True if code_interpreter_tool_call.outputs: for output in code_interpreter_tool_call.outputs: - if isinstance(output, RunStepDeltaCodeInterpreterImageOutput) and output.image.file_id: + if ( + isinstance(output, RunStepDeltaCodeInterpreterImageOutput) + and output.image is not None + and output.image.file_id + ): items.append( StreamingFileReferenceContent( file_id=output.image.file_id, diff --git a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py index 8abbcbb1eca4..49324474da63 100644 --- a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py +++ b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py @@ -541,7 +541,8 @@ async def _stream_tool_outputs( if sub_event_type == AgentStreamEvent.THREAD_MESSAGE_DELTA: yield generate_streaming_message_content(agent.name, sub_event_data) elif sub_event_type == AgentStreamEvent.THREAD_RUN_COMPLETED: - logger.info(f"Run completed with ID: {sub_event_data.id}") + thread_run = cast(ThreadRun, sub_event_data) + logger.info(f"Run completed with ID: {thread_run.id}") if active_messages: for msg_id, step in active_messages.items(): message = await cls._retrieve_message(agent=agent, thread_id=thread_id, message_id=msg_id) @@ -551,7 +552,7 @@ async def _stream_tool_outputs( messages.append(final_content) return elif sub_event_type == AgentStreamEvent.THREAD_RUN_FAILED: - run_failed: ThreadRun = sub_event_data + run_failed = cast(ThreadRun, sub_event_data) error_message = ( run_failed.last_error.message if run_failed.last_error and run_failed.last_error.message else "" ) diff --git a/python/semantic_kernel/agents/bedrock/bedrock_agent.py b/python/semantic_kernel/agents/bedrock/bedrock_agent.py index 1e746a050b5c..ccfb14d1fe6c 100644 --- a/python/semantic_kernel/agents/bedrock/bedrock_agent.py +++ b/python/semantic_kernel/agents/bedrock/bedrock_agent.py @@ -18,6 +18,7 @@ from semantic_kernel.agents.bedrock.bedrock_agent_settings import BedrockAgentSettings from semantic_kernel.agents.bedrock.models.bedrock_agent_event_type import BedrockAgentEventType from semantic_kernel.agents.bedrock.models.bedrock_agent_model import BedrockAgentModel +from semantic_kernel.agents.channels.agent_channel import AgentChannel from semantic_kernel.agents.channels.bedrock_agent_channel import BedrockAgentChannel from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.contents.binary_content import BinaryContent @@ -44,7 +45,7 @@ class BedrockAgent(BedrockAgentBase, Agent): Manages the interaction with Amazon Bedrock Agent Service. """ - channel_type: ClassVar[type[BedrockAgentChannel]] = BedrockAgentChannel + channel_type: ClassVar[type[AgentChannel]] = BedrockAgentChannel def __init__( self, @@ -93,9 +94,9 @@ def __init__( raise AgentInitializationException("Failed to initialize the Amazon Bedrock Agent settings.") from e bedrock_agent_model = BedrockAgentModel( - agent_id=id, - agent_name=name, - foundation_model=bedrock_agent_settings.foundation_model, + agentId=id, + agentName=name, + foundationModel=bedrock_agent_settings.foundation_model, ) prompt_template: PromptTemplateBase | None = None diff --git a/python/semantic_kernel/agents/chat_completion/chat_completion_agent.py b/python/semantic_kernel/agents/chat_completion/chat_completion_agent.py index 187f5bc9457f..9b4b6a3f9a63 100644 --- a/python/semantic_kernel/agents/chat_completion/chat_completion_agent.py +++ b/python/semantic_kernel/agents/chat_completion/chat_completion_agent.py @@ -123,6 +123,9 @@ async def invoke( else: arguments.update(kwargs) + # Add the chat history to the args in the event that it is needed for prompt template configuration + arguments["chat_history"] = history + kernel = kernel or self.kernel arguments = self.merge_arguments(arguments) @@ -188,6 +191,9 @@ async def invoke_stream( else: arguments.update(kwargs) + # Add the chat history to the args in the event that it is needed for prompt template configuration + arguments["chat_history"] = history + kernel = kernel or self.kernel arguments = self.merge_arguments(arguments) @@ -245,11 +251,13 @@ async def _setup_agent_chat_history( self, history: ChatHistory, kernel: "Kernel", arguments: KernelArguments ) -> ChatHistory: """Setup the agent chat history.""" - return ( - ChatHistory(messages=history.messages) - if self.instructions is None - else ChatHistory(system_message=self.instructions, messages=history.messages) - ) + formatted_instructions = await self.format_instructions(kernel, arguments) + messages = [] + if formatted_instructions: + messages.append(ChatMessageContent(role=AuthorRole.SYSTEM, content=formatted_instructions, name=self.name)) + if history.messages: + messages.extend(history.messages) + return ChatHistory(messages=messages) async def _get_chat_completion_service_and_settings( self, kernel: "Kernel", arguments: KernelArguments diff --git a/python/semantic_kernel/agents/group_chat/agent_chat.py b/python/semantic_kernel/agents/group_chat/agent_chat.py index e6b4da0e2242..7fa9778ae774 100644 --- a/python/semantic_kernel/agents/group_chat/agent_chat.py +++ b/python/semantic_kernel/agents/group_chat/agent_chat.py @@ -65,6 +65,8 @@ async def get_chat_messages(self, agent: "Agent | None" = None) -> AsyncIterable self.set_activity_or_throw() logger.info("Getting chat messages") + + messages: AsyncIterable[ChatMessageContent] | None = None try: if agent is None: messages = self.get_messages_in_descending_order() diff --git a/python/semantic_kernel/agents/group_chat/agent_group_chat.py b/python/semantic_kernel/agents/group_chat/agent_group_chat.py index 373404e1420d..0922d209cb2c 100644 --- a/python/semantic_kernel/agents/group_chat/agent_group_chat.py +++ b/python/semantic_kernel/agents/group_chat/agent_group_chat.py @@ -3,7 +3,7 @@ import logging from collections.abc import AsyncIterable from copy import deepcopy -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from pydantic import Field @@ -217,11 +217,12 @@ async def reduce_history(self) -> bool: if not isinstance(self.history, ChatHistoryReducer): return False - reducer = await self.history.reduce() - if reducer is not None: - reduced_history = deepcopy(reducer.messages) - await self.reset() - await self.add_chat_messages(reduced_history) - return True + result = await self.history.reduce() + if result is None: + return False - return False + reducer = cast(ChatHistoryReducer, result) + reduced_history = deepcopy(reducer.messages) + await self.reset() + await self.add_chat_messages(reduced_history) + return True diff --git a/python/semantic_kernel/agents/open_ai/assistant_content_generation.py b/python/semantic_kernel/agents/open_ai/assistant_content_generation.py index 483586fedf02..5a3673e0fdd3 100644 --- a/python/semantic_kernel/agents/open_ai/assistant_content_generation.py +++ b/python/semantic_kernel/agents/open_ai/assistant_content_generation.py @@ -3,13 +3,15 @@ from typing import TYPE_CHECKING, Any from openai import AsyncOpenAI +from openai.types.beta.threads.file_citation_annotation import FileCitationAnnotation from openai.types.beta.threads.file_citation_delta_annotation import FileCitationDeltaAnnotation +from openai.types.beta.threads.file_path_annotation import FilePathAnnotation from openai.types.beta.threads.file_path_delta_annotation import FilePathDeltaAnnotation from openai.types.beta.threads.image_file_content_block import ImageFileContentBlock from openai.types.beta.threads.image_file_delta_block import ImageFileDeltaBlock from openai.types.beta.threads.message_delta_event import MessageDeltaEvent from openai.types.beta.threads.runs import CodeInterpreterLogs -from openai.types.beta.threads.runs.code_interpreter_tool_call import CodeInterpreter +from openai.types.beta.threads.runs.code_interpreter_tool_call import CodeInterpreterOutputImage from openai.types.beta.threads.text_content_block import TextContentBlock from openai.types.beta.threads.text_delta_block import TextDeltaBlock @@ -29,9 +31,8 @@ from semantic_kernel.utils.experimental_decorator import experimental_function if TYPE_CHECKING: - from openai.resources.beta.threads.messages import Message - from openai.resources.beta.threads.runs.runs import Run - from openai.types.beta.threads.annotation import Annotation + from openai.types.beta.threads.message import Message + from openai.types.beta.threads.run import Run from openai.types.beta.threads.runs import RunStep from openai.types.beta.threads.runs.tool_call import ToolCall from openai.types.beta.threads.runs.tool_calls_step_details import ToolCallsStepDetails @@ -348,7 +349,7 @@ def generate_streaming_code_interpreter_content( metadata["code"] = True if tool.code_interpreter.outputs: for output in tool.code_interpreter.outputs: - if isinstance(output, CodeInterpreter) and output.image.file_id: + if isinstance(output, CodeInterpreterOutputImage) and output.image.file_id: items.append( StreamingFileReferenceContent( file_id=output.image.file_id, @@ -376,13 +377,14 @@ def generate_streaming_code_interpreter_content( @experimental_function -def generate_annotation_content(annotation: "Annotation") -> AnnotationContent: +def generate_annotation_content(annotation: FileCitationAnnotation | FilePathAnnotation) -> AnnotationContent: """Generate annotation content.""" file_id = None - if hasattr(annotation, "file_path"): - file_id = annotation.file_path.file_id - elif hasattr(annotation, "file_citation"): - file_id = annotation.file_citation.file_id + match annotation: + case FilePathAnnotation(): + file_id = annotation.file_path.file_id + case FileCitationAnnotation(): + file_id = annotation.file_citation.file_id return AnnotationContent( file_id=file_id, @@ -393,13 +395,16 @@ def generate_annotation_content(annotation: "Annotation") -> AnnotationContent: @experimental_function -def generate_streaming_annotation_content(annotation: "Annotation") -> StreamingAnnotationContent: +def generate_streaming_annotation_content( + annotation: FilePathDeltaAnnotation | FileCitationDeltaAnnotation, +) -> StreamingAnnotationContent: """Generate streaming annotation content.""" file_id = None - if hasattr(annotation, "file_path") and annotation.file_path: - file_id = annotation.file_path.file_id if annotation.file_path.file_id else None - elif hasattr(annotation, "file_citation") and annotation.file_citation: - file_id = annotation.file_citation.file_id if annotation.file_citation.file_id else None + match annotation: + case FilePathDeltaAnnotation(): + file_id = annotation.file_path.file_id if annotation.file_path is not None else None + case FileCitationDeltaAnnotation(): + file_id = annotation.file_citation.file_id if annotation.file_citation is not None else None return StreamingAnnotationContent( file_id=file_id, diff --git a/python/semantic_kernel/agents/open_ai/open_ai_assistant_base.py b/python/semantic_kernel/agents/open_ai/open_ai_assistant_base.py index 7885bf857168..7a6a35a5244f 100644 --- a/python/semantic_kernel/agents/open_ai/open_ai_assistant_base.py +++ b/python/semantic_kernel/agents/open_ai/open_ai_assistant_base.py @@ -4,14 +4,21 @@ import json import logging from collections.abc import AsyncIterable, Iterable -from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, cast from openai import AsyncOpenAI -from openai.resources.beta.assistants import Assistant -from openai.resources.beta.threads.messages import Message -from openai.resources.beta.threads.runs.runs import Run -from openai.types.beta.assistant_tool import CodeInterpreterTool, FileSearchTool -from openai.types.beta.threads.runs import RunStep +from openai.types.beta.assistant import Assistant +from openai.types.beta.code_interpreter_tool import CodeInterpreterTool +from openai.types.beta.file_search_tool import FileSearchTool +from openai.types.beta.threads.message import Message +from openai.types.beta.threads.run import Run +from openai.types.beta.threads.runs import ( + MessageCreationStepDetails, + RunStep, + RunStepDeltaEvent, + ToolCallDeltaObject, + ToolCallsStepDetails, +) from pydantic import Field from semantic_kernel.agents import Agent @@ -326,7 +333,7 @@ async def create_assistant( if "metadata" not in create_assistant_kwargs: create_assistant_kwargs["metadata"] = {} if self._options_metadata_key not in create_assistant_kwargs["metadata"]: - create_assistant_kwargs["metadata"][self._options_metadata_key] = {} + create_assistant_kwargs["metadata"][self._options_metadata_key] = "" create_assistant_kwargs["metadata"][self._options_metadata_key] = json.dumps(execution_settings) self.assistant = await self.client.beta.assistants.create( @@ -874,7 +881,8 @@ def sort_key(step: RunStep): f"thread `{thread_id}`" ) assert hasattr(completed_step.step_details, "tool_calls") # nosec - for tool_call in completed_step.step_details.tool_calls: + tool_call_details = cast(ToolCallsStepDetails, completed_step.step_details) + for tool_call in tool_call_details.tool_calls: is_visible = False content: "ChatMessageContent | None" = None if tool_call.type == "code_interpreter": @@ -1092,20 +1100,20 @@ async def _invoke_internal_stream( content = generate_streaming_message_content(self.name, event.data) yield content elif event.event == "thread.run.step.completed": + step_completed = cast(RunStep, event.data) logger.info(f"Run step completed with ID: {event.data.id}") - if hasattr(event.data.step_details, "message_creation"): - message_id = event.data.step_details.message_creation.message_id + if isinstance(step_completed.step_details, MessageCreationStepDetails): + message_id = step_completed.step_details.message_creation.message_id if message_id not in active_messages: active_messages[message_id] = event.data elif event.event == "thread.run.step.delta": + run_step_event: RunStepDeltaEvent = event.data + details = run_step_event.delta.step_details + if not details: + continue step_details = event.data.delta.step_details - if ( - step_details is not None - and hasattr(step_details, "tool_calls") - and step_details.tool_calls is not None - and isinstance(step_details.tool_calls, list) - ): - for tool_call in step_details.tool_calls: + if isinstance(details, ToolCallDeltaObject) and details.tool_calls: + for tool_call in details.tool_calls: tool_content = None if tool_call.type == "function": tool_content = generate_streaming_function_content(self.name, step_details) diff --git a/python/tests/unit/agents/test_chat_completion_agent.py b/python/tests/unit/agents/test_chat_completion_agent.py index 501cc90d31e9..02a1be996acc 100644 --- a/python/tests/unit/agents/test_chat_completion_agent.py +++ b/python/tests/unit/agents/test_chat_completion_agent.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +from collections.abc import AsyncGenerator, Callable from unittest.mock import AsyncMock, create_autospec, patch import pytest @@ -18,15 +19,13 @@ @pytest.fixture -def mock_streaming_chat_completion_response() -> AsyncMock: - """A fixture that returns a mock response for a streaming chat completion response.""" - +def mock_streaming_chat_completion_response() -> Callable[..., AsyncGenerator[list[ChatMessageContent], None]]: async def mock_response( chat_history: ChatHistory, settings: PromptExecutionSettings, kernel: Kernel, arguments: KernelArguments, - ): + ) -> AsyncGenerator[list[ChatMessageContent], None]: content1 = ChatMessageContent(role=AuthorRole.SYSTEM, content="Processed Message 1") content2 = ChatMessageContent(role=AuthorRole.TOOL, content="Processed Message 2") chat_history.messages.append(content1) @@ -242,3 +241,41 @@ async def test_create_channel(): channel = await agent.create_channel() assert isinstance(channel, ChatHistoryChannel) + + +async def test_setup_agent_chat_history_with_formatted_instructions(): + agent = ChatCompletionAgent( + name="TestAgent", id="test_id", description="Test Description", instructions="Test Instructions" + ) + with patch.object( + ChatCompletionAgent, "format_instructions", new=AsyncMock(return_value="Formatted instructions for testing") + ) as mock_format_instructions: + dummy_kernel = create_autospec(Kernel) + dummy_args = KernelArguments(param="value") + user_message = ChatMessageContent(role=AuthorRole.USER, content="User message") + history = ChatHistory(messages=[user_message]) + result_history = await agent._setup_agent_chat_history(history, dummy_kernel, dummy_args) + mock_format_instructions.assert_awaited_once_with(dummy_kernel, dummy_args) + assert len(result_history.messages) == 2 + system_message = result_history.messages[0] + assert system_message.role == AuthorRole.SYSTEM + assert system_message.content == "Formatted instructions for testing" + assert system_message.name == agent.name + assert result_history.messages[1] == user_message + + +async def test_setup_agent_chat_history_without_formatted_instructions(): + agent = ChatCompletionAgent( + name="TestAgent", id="test_id", description="Test Description", instructions="Test Instructions" + ) + with patch.object( + ChatCompletionAgent, "format_instructions", new=AsyncMock(return_value=None) + ) as mock_format_instructions: + dummy_kernel = create_autospec(Kernel) + dummy_args = KernelArguments(param="value") + user_message = ChatMessageContent(role=AuthorRole.USER, content="User message") + history = ChatHistory(messages=[user_message]) + result_history = await agent._setup_agent_chat_history(history, dummy_kernel, dummy_args) + mock_format_instructions.assert_awaited_once_with(dummy_kernel, dummy_args) + assert len(result_history.messages) == 1 + assert result_history.messages[0] == user_message