chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import Sequence
|
||||
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 CodeInterpreterOutputImage
|
||||
from openai.types.beta.threads.text_content_block import TextContentBlock
|
||||
from openai.types.beta.threads.text_delta_block import TextDeltaBlock
|
||||
|
||||
from semantic_kernel.contents.annotation_content import AnnotationContent
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.file_reference_content import FileReferenceContent
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
from semantic_kernel.contents.function_result_content import FunctionResultContent
|
||||
from semantic_kernel.contents.image_content import ImageContent
|
||||
from semantic_kernel.contents.streaming_annotation_content import StreamingAnnotationContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.streaming_file_reference_content import StreamingFileReferenceContent
|
||||
from semantic_kernel.contents.streaming_text_content import StreamingTextContent
|
||||
from semantic_kernel.contents.text_content import TextContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentExecutionException
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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
|
||||
|
||||
|
||||
###################################################################
|
||||
# The methods in this file are used with OpenAIAssistantAgent #
|
||||
# related code. They are used to create chat messages, or #
|
||||
# generate message content. #
|
||||
###################################################################
|
||||
|
||||
|
||||
@experimental
|
||||
async def create_chat_message(
|
||||
client: AsyncOpenAI,
|
||||
thread_id: str,
|
||||
message: "ChatMessageContent",
|
||||
allowed_message_roles: Sequence[str] | None = None,
|
||||
) -> "Message":
|
||||
"""Class method to add a chat message, callable from class or instance.
|
||||
|
||||
Args:
|
||||
client: The client to use for creating the message.
|
||||
thread_id: The thread id.
|
||||
message: The chat message.
|
||||
allowed_message_roles: The allowed message roles.
|
||||
Defaults to [AuthorRole.USER, AuthorRole.ASSISTANT] if None.
|
||||
Providing an empty list will disallow all message roles.
|
||||
|
||||
Returns:
|
||||
Message: The message.
|
||||
"""
|
||||
# Set the default allowed message roles if not provided
|
||||
if allowed_message_roles is None:
|
||||
allowed_message_roles = [AuthorRole.USER, AuthorRole.ASSISTANT]
|
||||
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}."
|
||||
)
|
||||
|
||||
message_contents: list[dict[str, Any]] = get_message_contents(message=message)
|
||||
|
||||
return await client.beta.threads.messages.create(
|
||||
thread_id=thread_id,
|
||||
role="assistant" if message.role == AuthorRole.TOOL else message.role.value, # type: ignore
|
||||
content=message_contents, # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@experimental
|
||||
def get_message_contents(message: "ChatMessageContent") -> list[dict[str, Any]]:
|
||||
"""Get the message contents.
|
||||
|
||||
Args:
|
||||
message: The message.
|
||||
"""
|
||||
contents: list[dict[str, Any]] = []
|
||||
for content in message.items:
|
||||
match content:
|
||||
case TextContent():
|
||||
# Make sure text is a string
|
||||
final_text = content.text
|
||||
if not isinstance(final_text, str):
|
||||
if isinstance(final_text, (list, tuple)):
|
||||
final_text = " ".join(map(str, final_text))
|
||||
else:
|
||||
final_text = str(final_text)
|
||||
|
||||
contents.append({"type": "text", "text": final_text})
|
||||
|
||||
case ImageContent():
|
||||
if content.uri:
|
||||
contents.append(content.to_dict())
|
||||
|
||||
case FileReferenceContent():
|
||||
contents.append({
|
||||
"type": "image_file",
|
||||
"image_file": {"file_id": content.file_id},
|
||||
})
|
||||
|
||||
case FunctionResultContent():
|
||||
final_result = content.result
|
||||
match final_result:
|
||||
case str():
|
||||
contents.append({"type": "text", "text": final_result})
|
||||
case list() | tuple():
|
||||
contents.append({"type": "text", "text": " ".join(map(str, final_result))})
|
||||
case _:
|
||||
contents.append({"type": "text", "text": str(final_result)})
|
||||
|
||||
return contents
|
||||
|
||||
|
||||
@experimental
|
||||
def generate_message_content(
|
||||
assistant_name: str, message: "Message", completed_step: "RunStep | None" = None
|
||||
) -> ChatMessageContent:
|
||||
"""Generate message content."""
|
||||
role = AuthorRole(message.role)
|
||||
|
||||
metadata = (
|
||||
{
|
||||
"created_at": completed_step.created_at,
|
||||
"message_id": message.id, # message needs to be defined in context
|
||||
"step_id": completed_step.id,
|
||||
"run_id": completed_step.run_id,
|
||||
"thread_id": completed_step.thread_id,
|
||||
"assistant_id": completed_step.assistant_id,
|
||||
"usage": completed_step.usage,
|
||||
}
|
||||
if completed_step is not None
|
||||
else None
|
||||
)
|
||||
|
||||
content: ChatMessageContent = ChatMessageContent(role=role, name=assistant_name, metadata=metadata) # type: ignore
|
||||
|
||||
for item_content in message.content:
|
||||
if item_content.type == "text":
|
||||
assert isinstance(item_content, TextContentBlock) # nosec
|
||||
content.items.append(
|
||||
TextContent(
|
||||
text=item_content.text.value,
|
||||
)
|
||||
)
|
||||
for annotation in item_content.text.annotations:
|
||||
content.items.append(generate_annotation_content(annotation))
|
||||
elif item_content.type == "image_file":
|
||||
assert isinstance(item_content, ImageFileContentBlock) # nosec
|
||||
content.items.append(
|
||||
FileReferenceContent(
|
||||
file_id=item_content.image_file.file_id,
|
||||
)
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
@experimental
|
||||
def generate_streaming_message_content(
|
||||
assistant_name: str,
|
||||
message_delta_event: "MessageDeltaEvent",
|
||||
completed_step: "RunStep | None" = None,
|
||||
) -> StreamingChatMessageContent:
|
||||
"""Generate streaming message content from a MessageDeltaEvent."""
|
||||
delta = message_delta_event.delta
|
||||
|
||||
metadata = (
|
||||
{
|
||||
"created_at": completed_step.created_at,
|
||||
"message_id": message_delta_event.id, # message needs to be defined in context
|
||||
"step_id": completed_step.id,
|
||||
"run_id": completed_step.run_id,
|
||||
"thread_id": completed_step.thread_id,
|
||||
"assistant_id": completed_step.assistant_id,
|
||||
"usage": completed_step.usage,
|
||||
}
|
||||
if completed_step is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Determine the role
|
||||
role = AuthorRole(delta.role) if delta.role is not None else AuthorRole("assistant")
|
||||
|
||||
items: list[StreamingTextContent | StreamingAnnotationContent | StreamingFileReferenceContent] = []
|
||||
|
||||
# Process each content block in the delta
|
||||
for delta_block in delta.content or []:
|
||||
if delta_block.type == "text":
|
||||
assert isinstance(delta_block, TextDeltaBlock) # nosec
|
||||
if delta_block.text and delta_block.text.value: # Ensure text is not None
|
||||
text_value = delta_block.text.value
|
||||
items.append(
|
||||
StreamingTextContent(
|
||||
text=text_value,
|
||||
choice_index=delta_block.index,
|
||||
)
|
||||
)
|
||||
# Process annotations if any
|
||||
if delta_block.text.annotations:
|
||||
for annotation in delta_block.text.annotations or []:
|
||||
if isinstance(annotation, (FileCitationDeltaAnnotation, FilePathDeltaAnnotation)):
|
||||
items.append(generate_streaming_annotation_content(annotation))
|
||||
elif delta_block.type == "image_file":
|
||||
assert isinstance(delta_block, ImageFileDeltaBlock) # nosec
|
||||
if delta_block.image_file and delta_block.image_file.file_id:
|
||||
file_id = delta_block.image_file.file_id
|
||||
items.append(
|
||||
StreamingFileReferenceContent(
|
||||
file_id=file_id,
|
||||
)
|
||||
)
|
||||
|
||||
return StreamingChatMessageContent(role=role, name=assistant_name, items=items, choice_index=0, metadata=metadata) # type: ignore
|
||||
|
||||
|
||||
@experimental
|
||||
def generate_final_streaming_message_content(
|
||||
assistant_name: str,
|
||||
message: "Message",
|
||||
completed_step: "RunStep | None" = None,
|
||||
) -> StreamingChatMessageContent:
|
||||
"""Generate streaming message content from a MessageDeltaEvent."""
|
||||
metadata = (
|
||||
{
|
||||
"created_at": completed_step.created_at,
|
||||
"message_id": message.id, # message needs to be defined in context
|
||||
"step_id": completed_step.id,
|
||||
"run_id": completed_step.run_id,
|
||||
"thread_id": completed_step.thread_id,
|
||||
"assistant_id": completed_step.assistant_id,
|
||||
"usage": completed_step.usage,
|
||||
}
|
||||
if completed_step is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Determine the role
|
||||
role = AuthorRole(message.role) if message.role is not None else AuthorRole("assistant")
|
||||
|
||||
items: list[StreamingTextContent | StreamingAnnotationContent | StreamingFileReferenceContent] = []
|
||||
|
||||
# Process each content block in the delta
|
||||
for item_content in message.content:
|
||||
if item_content.type == "text":
|
||||
assert isinstance(item_content, TextContentBlock) # nosec
|
||||
items.append(StreamingTextContent(text=item_content.text.value, choice_index=0))
|
||||
for annotation in item_content.text.annotations:
|
||||
items.append(generate_streaming_annotation_content(annotation))
|
||||
elif item_content.type == "image_file":
|
||||
assert isinstance(item_content, ImageFileContentBlock) # nosec
|
||||
items.append(
|
||||
StreamingFileReferenceContent(
|
||||
file_id=item_content.image_file.file_id,
|
||||
)
|
||||
)
|
||||
|
||||
return StreamingChatMessageContent(role=role, name=assistant_name, items=items, choice_index=0, metadata=metadata) # type: ignore
|
||||
|
||||
|
||||
@experimental
|
||||
def merge_function_results(messages: list["ChatMessageContent"], name: str) -> "ChatMessageContent":
|
||||
"""Combine multiple function result content types to one chat message content type.
|
||||
|
||||
This method combines the FunctionResultContent items from separate ChatMessageContent messages,
|
||||
and is used in the event that the `context.terminate = True` condition is met.
|
||||
|
||||
Args:
|
||||
messages: The list of chat messages.
|
||||
name: The name of the agent.
|
||||
|
||||
Returns:
|
||||
list[ChatMessageContent]: The combined chat message content.
|
||||
"""
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.function_result_content import FunctionResultContent
|
||||
|
||||
items: list[Any] = []
|
||||
for message in messages:
|
||||
items.extend([item for item in message.items if isinstance(item, FunctionResultContent)])
|
||||
return ChatMessageContent(
|
||||
role=AuthorRole.TOOL,
|
||||
items=items,
|
||||
name=name,
|
||||
)
|
||||
|
||||
|
||||
@experimental
|
||||
def merge_streaming_function_results(
|
||||
messages: list["ChatMessageContent | StreamingChatMessageContent"],
|
||||
name: str,
|
||||
ai_model_id: str | None = None,
|
||||
function_invoke_attempt: int | None = None,
|
||||
) -> "StreamingChatMessageContent":
|
||||
"""Combine multiple streaming function result content types to one streaming chat message content type.
|
||||
|
||||
This method combines the FunctionResultContent items from separate StreamingChatMessageContent messages,
|
||||
and is used in the event that the `context.terminate = True` condition is met.
|
||||
|
||||
Args:
|
||||
messages: The list of streaming chat message content types.
|
||||
name: The name of the agent.
|
||||
ai_model_id: The AI model ID.
|
||||
function_invoke_attempt: The function invoke attempt.
|
||||
|
||||
Returns:
|
||||
The combined streaming chat message content type.
|
||||
"""
|
||||
from semantic_kernel.contents.function_result_content import FunctionResultContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
|
||||
items: list[Any] = []
|
||||
for message in messages:
|
||||
items.extend([item for item in message.items if isinstance(item, FunctionResultContent)])
|
||||
|
||||
return StreamingChatMessageContent(
|
||||
name=name,
|
||||
role=AuthorRole.TOOL,
|
||||
items=items,
|
||||
choice_index=0,
|
||||
ai_model_id=ai_model_id,
|
||||
function_invoke_attempt=function_invoke_attempt,
|
||||
)
|
||||
|
||||
|
||||
@experimental
|
||||
def generate_function_call_content(agent_name: str, fccs: list[FunctionCallContent]) -> ChatMessageContent:
|
||||
"""Generate function call content.
|
||||
|
||||
Args:
|
||||
agent_name: The agent name.
|
||||
fccs: The function call contents.
|
||||
|
||||
Returns:
|
||||
ChatMessageContent: The chat message content containing the function call content as the items.
|
||||
"""
|
||||
return ChatMessageContent(role=AuthorRole.ASSISTANT, name=agent_name, items=fccs) # type: ignore
|
||||
|
||||
|
||||
@experimental
|
||||
def generate_function_result_content(
|
||||
agent_name: str, function_step: FunctionCallContent, tool_call: "ToolCall"
|
||||
) -> ChatMessageContent:
|
||||
"""Generate function result content."""
|
||||
function_call_content: ChatMessageContent = ChatMessageContent(role=AuthorRole.TOOL, name=agent_name) # type: ignore
|
||||
function_call_content.items.append(
|
||||
FunctionResultContent(
|
||||
function_name=function_step.function_name,
|
||||
plugin_name=function_step.plugin_name,
|
||||
id=function_step.id,
|
||||
result=tool_call.function.output, # type: ignore
|
||||
)
|
||||
)
|
||||
return function_call_content
|
||||
|
||||
|
||||
@experimental
|
||||
def get_function_call_contents(run: "Run", function_steps: dict[str, FunctionCallContent]) -> list[FunctionCallContent]:
|
||||
"""Extract function call contents from the run.
|
||||
|
||||
Args:
|
||||
run: The run.
|
||||
function_steps: The function steps
|
||||
|
||||
Returns:
|
||||
The list of function call contents.
|
||||
"""
|
||||
function_call_contents: list[FunctionCallContent] = []
|
||||
required_action = getattr(run, "required_action", None)
|
||||
if not required_action or not getattr(required_action, "submit_tool_outputs", False):
|
||||
return function_call_contents
|
||||
for tool in required_action.submit_tool_outputs.tool_calls:
|
||||
fcc = FunctionCallContent(
|
||||
id=tool.id,
|
||||
index=getattr(tool, "index", None),
|
||||
name=tool.function.name,
|
||||
arguments=tool.function.arguments,
|
||||
)
|
||||
function_call_contents.append(fcc)
|
||||
function_steps[tool.id] = fcc
|
||||
return function_call_contents
|
||||
|
||||
|
||||
@experimental
|
||||
def generate_code_interpreter_content(agent_name: str, code: str) -> "ChatMessageContent":
|
||||
"""Generate code interpreter content.
|
||||
|
||||
Args:
|
||||
agent_name: The agent name.
|
||||
code: The code.
|
||||
|
||||
Returns:
|
||||
ChatMessageContent: The chat message content.
|
||||
"""
|
||||
return ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
content=code,
|
||||
name=agent_name,
|
||||
metadata={"code": True},
|
||||
)
|
||||
|
||||
|
||||
@experimental
|
||||
def generate_streaming_function_content(
|
||||
agent_name: str, step_details: "ToolCallsStepDetails"
|
||||
) -> "StreamingChatMessageContent":
|
||||
"""Generate streaming function content.
|
||||
|
||||
Args:
|
||||
agent_name: The agent name.
|
||||
step_details: The function step.
|
||||
|
||||
Returns:
|
||||
StreamingChatMessageContent: The chat message content.
|
||||
"""
|
||||
items: list[FunctionCallContent] = []
|
||||
|
||||
for tool in step_details.tool_calls:
|
||||
if tool.type == "function":
|
||||
items.append(
|
||||
FunctionCallContent(
|
||||
id=tool.id,
|
||||
index=getattr(tool, "index", None),
|
||||
name=tool.function.name,
|
||||
arguments=tool.function.arguments,
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
StreamingChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
name=agent_name,
|
||||
items=items, # type: ignore
|
||||
choice_index=0,
|
||||
)
|
||||
if len(items) > 0
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
@experimental
|
||||
def generate_streaming_code_interpreter_content(
|
||||
agent_name: str, step_details: "ToolCallsStepDetails"
|
||||
) -> "StreamingChatMessageContent | None":
|
||||
"""Generate code interpreter content.
|
||||
|
||||
Args:
|
||||
agent_name: The agent name.
|
||||
step_details: The current step details.
|
||||
|
||||
Returns:
|
||||
StreamingChatMessageContent: The chat message content.
|
||||
"""
|
||||
items: list[StreamingTextContent | StreamingFileReferenceContent] = []
|
||||
|
||||
metadata: dict[str, bool] = {}
|
||||
for index, tool in enumerate(step_details.tool_calls):
|
||||
if tool.type == "code_interpreter":
|
||||
if tool.code_interpreter.input:
|
||||
items.append(
|
||||
StreamingTextContent(
|
||||
choice_index=index,
|
||||
text=tool.code_interpreter.input,
|
||||
)
|
||||
)
|
||||
metadata["code"] = True
|
||||
if tool.code_interpreter.outputs:
|
||||
for output in tool.code_interpreter.outputs:
|
||||
if isinstance(output, CodeInterpreterOutputImage) and output.image.file_id:
|
||||
items.append(
|
||||
StreamingFileReferenceContent(
|
||||
file_id=output.image.file_id,
|
||||
)
|
||||
)
|
||||
if isinstance(output, CodeInterpreterLogs) and output.logs:
|
||||
items.append(
|
||||
StreamingTextContent(
|
||||
choice_index=index,
|
||||
text=output.logs,
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
StreamingChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
name=agent_name,
|
||||
items=items, # type: ignore
|
||||
choice_index=0,
|
||||
metadata=metadata if metadata else None,
|
||||
)
|
||||
if len(items) > 0
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
@experimental
|
||||
def generate_annotation_content(annotation: FileCitationAnnotation | FilePathAnnotation) -> AnnotationContent:
|
||||
"""Generate annotation content."""
|
||||
file_id = None
|
||||
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,
|
||||
quote=annotation.text,
|
||||
start_index=annotation.start_index,
|
||||
end_index=annotation.end_index,
|
||||
)
|
||||
|
||||
|
||||
@experimental
|
||||
def generate_streaming_annotation_content(
|
||||
annotation: FileCitationAnnotation | FilePathAnnotation | FilePathDeltaAnnotation | FileCitationDeltaAnnotation,
|
||||
) -> StreamingAnnotationContent:
|
||||
"""Generate streaming annotation content."""
|
||||
file_id = None
|
||||
match annotation:
|
||||
case FilePathAnnotation():
|
||||
file_id = annotation.file_path.file_id
|
||||
case FileCitationAnnotation():
|
||||
file_id = annotation.file_citation.file_id
|
||||
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,
|
||||
quote=annotation.text,
|
||||
start_index=annotation.start_index,
|
||||
end_index=annotation.end_index,
|
||||
)
|
||||
|
||||
|
||||
@experimental
|
||||
def generate_function_call_streaming_content(
|
||||
agent_name: str,
|
||||
fccs: list[FunctionCallContent],
|
||||
) -> StreamingChatMessageContent:
|
||||
"""Generate function call content.
|
||||
|
||||
Args:
|
||||
agent_name: The agent name.
|
||||
fccs: The function call contents.
|
||||
|
||||
Returns:
|
||||
StreamingChatMessageContent: The chat message content containing the function call content as the items.
|
||||
"""
|
||||
return StreamingChatMessageContent(role=AuthorRole.ASSISTANT, choice_index=0, name=agent_name, items=fccs) # type: ignore
|
||||
@@ -0,0 +1,950 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterable, Iterable, Sequence
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar, cast
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
from openai._types import Omit, omit
|
||||
from openai.types.beta.code_interpreter_tool import CodeInterpreterTool
|
||||
from openai.types.beta.file_search_tool import FileSearchTool
|
||||
from openai.types.beta.threads.run_create_params import AdditionalMessage, AdditionalMessageAttachment
|
||||
from openai.types.beta.threads.runs import (
|
||||
MessageCreationStepDetails,
|
||||
RunStep,
|
||||
RunStepDeltaEvent,
|
||||
ToolCallDeltaObject,
|
||||
ToolCallsStepDetails,
|
||||
)
|
||||
|
||||
from semantic_kernel.agents.open_ai.assistant_content_generation import (
|
||||
generate_code_interpreter_content,
|
||||
generate_final_streaming_message_content,
|
||||
generate_function_call_content,
|
||||
generate_function_call_streaming_content,
|
||||
generate_function_result_content,
|
||||
generate_message_content,
|
||||
generate_streaming_code_interpreter_content,
|
||||
generate_streaming_message_content,
|
||||
get_function_call_contents,
|
||||
get_message_contents,
|
||||
merge_streaming_function_results,
|
||||
)
|
||||
from semantic_kernel.agents.open_ai.function_action_result import FunctionActionResult
|
||||
from semantic_kernel.agents.open_ai.run_polling_options import RunPollingOptions
|
||||
from semantic_kernel.connectors.ai.function_calling_utils import kernel_function_metadata_to_function_call_format
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.connectors.ai.function_choice_type import FunctionChoiceType
|
||||
from semantic_kernel.contents.file_reference_content import FileReferenceContent
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
from semantic_kernel.contents.streaming_file_reference_content import StreamingFileReferenceContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentExecutionException, AgentInvokeException
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.utils.feature_stage_decorator import release_candidate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openai import AsyncOpenAI
|
||||
from openai.types.beta.assistant_response_format_option_param import AssistantResponseFormatOptionParam
|
||||
from openai.types.beta.assistant_tool_param import AssistantToolParam
|
||||
from openai.types.beta.threads.message import Message
|
||||
from openai.types.beta.threads.run import Run
|
||||
from openai.types.beta.threads.run_create_params import AdditionalMessageAttachmentTool, TruncationStrategy
|
||||
|
||||
from semantic_kernel.agents.open_ai.openai_assistant_agent import OpenAIAssistantAgent
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.filters.auto_function_invocation.auto_function_invocation_context import (
|
||||
AutoFunctionInvocationContext,
|
||||
)
|
||||
from semantic_kernel.kernel import Kernel
|
||||
|
||||
_T = TypeVar("_T", bound="AssistantThreadActions")
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@release_candidate
|
||||
class AssistantThreadActions:
|
||||
"""Assistant Thread Actions class."""
|
||||
|
||||
polling_status: ClassVar[list[str]] = ["queued", "in_progress", "cancelling"]
|
||||
error_message_states: ClassVar[list[str]] = ["failed", "cancelled", "expired", "incomplete"]
|
||||
|
||||
tool_metadata: ClassVar[dict[str, Sequence[Any]]] = {
|
||||
"file_search": [{"type": "file_search"}],
|
||||
"code_interpreter": [{"type": "code_interpreter"}],
|
||||
}
|
||||
|
||||
# region Messaging Handling Methods
|
||||
|
||||
@classmethod
|
||||
async def create_message(
|
||||
cls: type[_T],
|
||||
client: "AsyncOpenAI",
|
||||
thread_id: str,
|
||||
message: "str | ChatMessageContent",
|
||||
allowed_message_roles: Sequence[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> "Message | None":
|
||||
"""Create a message in the thread.
|
||||
|
||||
Args:
|
||||
client: The client to use to create the message.
|
||||
thread_id: The ID of the thread to create the message in.
|
||||
message: The message to create.
|
||||
allowed_message_roles: The allowed message roles.
|
||||
Defaults to [AuthorRole.USER, AuthorRole.ASSISTANT] if None.
|
||||
Providing an empty list will disallow all message roles.
|
||||
kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
The created message.
|
||||
"""
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
|
||||
if isinstance(message, str):
|
||||
message = ChatMessageContent(role=AuthorRole.USER, content=message)
|
||||
|
||||
if any(isinstance(item, FunctionCallContent) for item in message.items):
|
||||
return None
|
||||
|
||||
# Set the default allowed message roles if not provided
|
||||
if allowed_message_roles is None:
|
||||
allowed_message_roles = [AuthorRole.USER, AuthorRole.ASSISTANT]
|
||||
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}."
|
||||
)
|
||||
|
||||
message_contents: list[dict[str, Any]] = get_message_contents(message=message)
|
||||
|
||||
return await client.beta.threads.messages.create(
|
||||
thread_id=thread_id,
|
||||
role="assistant" if message.role == AuthorRole.TOOL else message.role.value, # type: ignore
|
||||
content=message_contents, # type: ignore
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# endregion
|
||||
|
||||
# region Invocation Methods
|
||||
|
||||
@classmethod
|
||||
async def invoke(
|
||||
cls: type[_T],
|
||||
*,
|
||||
agent: "OpenAIAssistantAgent",
|
||||
thread_id: str,
|
||||
additional_instructions: str | None = None,
|
||||
additional_messages: "list[ChatMessageContent] | None" = None,
|
||||
arguments: KernelArguments | None = None,
|
||||
instructions_override: str | None = None,
|
||||
kernel: "Kernel | None" = None,
|
||||
max_completion_tokens: int | None = None,
|
||||
max_prompt_tokens: int | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
model: str | None = None,
|
||||
parallel_tool_calls: bool | None = None,
|
||||
reasoning_effort: Literal["low", "medium", "high"] | None = None,
|
||||
response_format: "AssistantResponseFormatOptionParam | None" = None,
|
||||
tools: "list[AssistantToolParam] | None" = None,
|
||||
temperature: float | None = None,
|
||||
top_p: float | None = None,
|
||||
truncation_strategy: "TruncationStrategy | None" = None,
|
||||
polling_options: RunPollingOptions | None = None,
|
||||
function_choice_behavior: FunctionChoiceBehavior | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[tuple[bool, "ChatMessageContent"]]:
|
||||
"""Invoke the assistant.
|
||||
|
||||
Args:
|
||||
agent: The assistant agent.
|
||||
thread_id: The thread ID.
|
||||
arguments: The kernel arguments.
|
||||
kernel: The kernel.
|
||||
instructions_override: The instructions override.
|
||||
additional_instructions: The additional instructions.
|
||||
additional_messages: The additional messages.
|
||||
max_completion_tokens: The maximum completion tokens.
|
||||
max_prompt_tokens: The maximum prompt tokens.
|
||||
metadata: The metadata.
|
||||
model: The model.
|
||||
parallel_tool_calls: The parallel tool calls.
|
||||
reasoning_effort: The reasoning effort.
|
||||
response_format: The response format.
|
||||
tools: The SDK-level tools (e.g. CodeInterpreter, FileSearch). When provided,
|
||||
overrides the tools from the agent definition. Does not affect kernel function availability;
|
||||
use function_choice_behavior for that.
|
||||
temperature: The temperature.
|
||||
top_p: The top p.
|
||||
truncation_strategy: The truncation strategy.
|
||||
polling_options: The polling options defined at the run-level. These will override the agent-level
|
||||
polling options.
|
||||
function_choice_behavior: Controls which kernel functions are allowed to execute during this run.
|
||||
Use FunctionChoiceBehavior.Auto(filters={"included_functions": [...]}) to restrict to specific
|
||||
functions. Only Auto is supported; other types will raise an error.
|
||||
kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
An async iterable of tuple of the visibility of the message and the chat message content.
|
||||
"""
|
||||
arguments = KernelArguments() if arguments is None else KernelArguments(**arguments, **kwargs)
|
||||
kernel = kernel or agent.kernel
|
||||
|
||||
cls._validate_function_choice_behavior(function_choice_behavior)
|
||||
|
||||
tools = cls._get_tools(
|
||||
agent=agent, kernel=kernel, tools_override=tools, function_choice_behavior=function_choice_behavior
|
||||
) # type: ignore
|
||||
|
||||
base_instructions = await agent.format_instructions(kernel=kernel, arguments=arguments)
|
||||
|
||||
merged_instructions: str = ""
|
||||
if instructions_override is not None:
|
||||
merged_instructions = instructions_override
|
||||
elif base_instructions and additional_instructions:
|
||||
merged_instructions = f"{base_instructions}\n\n{additional_instructions}"
|
||||
else:
|
||||
merged_instructions = base_instructions or additional_instructions or ""
|
||||
|
||||
# form run options
|
||||
run_options = cls._generate_options(
|
||||
agent=agent,
|
||||
model=model,
|
||||
response_format=response_format,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
metadata=metadata,
|
||||
parallel_tool_calls_enabled=parallel_tool_calls,
|
||||
truncation_message_count=truncation_strategy,
|
||||
max_completion_tokens=max_completion_tokens,
|
||||
max_prompt_tokens=max_prompt_tokens,
|
||||
additional_messages=additional_messages,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
run_options = {k: v for k, v in run_options.items() if v is not None}
|
||||
|
||||
run = await agent.client.beta.threads.runs.create(
|
||||
assistant_id=agent.id,
|
||||
thread_id=thread_id,
|
||||
instructions=merged_instructions or agent.instructions,
|
||||
tools=tools, # type: ignore
|
||||
**run_options,
|
||||
)
|
||||
|
||||
processed_step_ids = set()
|
||||
function_steps: dict[str, "FunctionCallContent"] = {}
|
||||
|
||||
while run.status != "completed":
|
||||
run = await cls._poll_run_status(
|
||||
agent=agent, run=run, thread_id=thread_id, polling_options=polling_options or agent.polling_options
|
||||
)
|
||||
|
||||
if run.status in cls.error_message_states:
|
||||
error_message = ""
|
||||
if run.last_error and run.last_error.message:
|
||||
error_message = run.last_error.message
|
||||
incomplete_details = ""
|
||||
if run.incomplete_details:
|
||||
incomplete_details = str(run.incomplete_details.reason)
|
||||
raise AgentInvokeException(
|
||||
f"Run failed with status: `{run.status}` for agent `{agent.name}` and thread `{thread_id}` "
|
||||
f"with error: {error_message} or incomplete details: {incomplete_details}"
|
||||
)
|
||||
|
||||
# Check if function calling required
|
||||
if run.status == "requires_action":
|
||||
logger.debug(f"Run [{run.id}] requires action for agent `{agent.name}` and thread `{thread_id}`")
|
||||
fccs = get_function_call_contents(run, function_steps)
|
||||
if fccs:
|
||||
logger.debug(
|
||||
f"Yielding `generate_function_call_content` for agent `{agent.name}` and "
|
||||
f"thread `{thread_id}`, visibility False"
|
||||
)
|
||||
yield False, generate_function_call_content(agent_name=agent.name, fccs=fccs)
|
||||
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
|
||||
chat_history = ChatHistory()
|
||||
_ = await cls._invoke_function_calls(
|
||||
kernel=kernel,
|
||||
fccs=fccs,
|
||||
chat_history=chat_history,
|
||||
arguments=arguments,
|
||||
function_choice_behavior=function_choice_behavior,
|
||||
)
|
||||
|
||||
tool_outputs = cls._format_tool_outputs(fccs, chat_history)
|
||||
await agent.client.beta.threads.runs.submit_tool_outputs(
|
||||
run_id=run.id,
|
||||
thread_id=thread_id,
|
||||
tool_outputs=tool_outputs, # type: ignore
|
||||
)
|
||||
logger.debug(f"Submitted tool outputs for agent `{agent.name}` and thread `{thread_id}`")
|
||||
continue
|
||||
|
||||
steps_response = await agent.client.beta.threads.runs.steps.list(run_id=run.id, thread_id=thread_id)
|
||||
logger.debug(f"Called for steps_response for run [{run.id}] agent `{agent.name}` and thread `{thread_id}`")
|
||||
steps: list[RunStep] = steps_response.data
|
||||
|
||||
def sort_key(step: RunStep):
|
||||
# Put tool_calls first, then message_creation
|
||||
# If multiple steps share a type, break ties by completed_at
|
||||
return (0 if step.type == "tool_calls" else 1, step.completed_at)
|
||||
|
||||
completed_steps_to_process = sorted(
|
||||
[s for s in steps if s.completed_at is not None and s.id not in processed_step_ids], key=sort_key
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Completed steps to process for run [{run.id}] agent `{agent.name}` and thread `{thread_id}` "
|
||||
f"with length `{len(completed_steps_to_process)}`"
|
||||
)
|
||||
|
||||
message_count = 0
|
||||
for completed_step in completed_steps_to_process:
|
||||
if completed_step.type == "tool_calls":
|
||||
logger.debug(
|
||||
f"Entering step type tool_calls for run [{run.id}], agent `{agent.name}` and "
|
||||
f"thread `{thread_id}`"
|
||||
)
|
||||
assert hasattr(completed_step.step_details, "tool_calls") # nosec
|
||||
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":
|
||||
logger.debug(
|
||||
f"Entering step type tool_calls for run [{run.id}], [code_interpreter] for "
|
||||
f"agent `{agent.name}` and thread `{thread_id}`"
|
||||
)
|
||||
content = generate_code_interpreter_content(
|
||||
agent.name,
|
||||
tool_call.code_interpreter.input, # type: ignore
|
||||
)
|
||||
is_visible = True
|
||||
elif tool_call.type == "function":
|
||||
logger.debug(
|
||||
f"Entering step type tool_calls for run [{run.id}], [function] for agent "
|
||||
f"`{agent.name}` and thread `{thread_id}`"
|
||||
)
|
||||
function_step = function_steps.get(tool_call.id)
|
||||
assert function_step is not None # nosec
|
||||
content = generate_function_result_content(
|
||||
agent_name=agent.name, function_step=function_step, tool_call=tool_call
|
||||
)
|
||||
|
||||
if content:
|
||||
message_count += 1
|
||||
logger.debug(
|
||||
f"Yielding tool_message for run [{run.id}], agent `{agent.name}` and thread "
|
||||
f"`{thread_id}` and message count `{message_count}`, is_visible `{is_visible}`"
|
||||
)
|
||||
yield is_visible, content
|
||||
elif completed_step.type == "message_creation":
|
||||
logger.debug(
|
||||
f"Entering step type message_creation for run [{run.id}], agent `{agent.name}` and "
|
||||
f"thread `{thread_id}`"
|
||||
)
|
||||
message = await cls._retrieve_message(
|
||||
agent=agent,
|
||||
thread_id=thread_id,
|
||||
message_id=completed_step.step_details.message_creation.message_id, # type: ignore
|
||||
)
|
||||
if message:
|
||||
content = generate_message_content(agent.name, message, completed_step)
|
||||
if content and len(content.items) > 0:
|
||||
message_count += 1
|
||||
logger.debug(
|
||||
f"Yielding message_creation for run [{run.id}], agent `{agent.name}` and "
|
||||
f"thread `{thread_id}` and message count `{message_count}`, is_visible `{True}`"
|
||||
)
|
||||
yield True, content
|
||||
processed_step_ids.add(completed_step.id)
|
||||
|
||||
@classmethod
|
||||
async def invoke_stream(
|
||||
cls: type[_T],
|
||||
*,
|
||||
agent: "OpenAIAssistantAgent",
|
||||
thread_id: str,
|
||||
additional_instructions: str | None = None,
|
||||
additional_messages: "list[ChatMessageContent] | None" = None,
|
||||
arguments: KernelArguments | None = None,
|
||||
instructions_override: str | None = None,
|
||||
kernel: "Kernel | None" = None,
|
||||
max_completion_tokens: int | None = None,
|
||||
max_prompt_tokens: int | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
model: str | None = None,
|
||||
output_messages: list["ChatMessageContent"] | None = None,
|
||||
parallel_tool_calls: bool | None = None,
|
||||
reasoning_effort: Literal["low", "medium", "high"] | None = None,
|
||||
response_format: "AssistantResponseFormatOptionParam | None" = None,
|
||||
tools: "list[AssistantToolParam] | None" = None,
|
||||
temperature: float | None = None,
|
||||
top_p: float | None = None,
|
||||
truncation_strategy: "TruncationStrategy | None" = None,
|
||||
function_choice_behavior: FunctionChoiceBehavior | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable["StreamingChatMessageContent"]:
|
||||
"""Invoke the assistant.
|
||||
|
||||
Args:
|
||||
agent: The assistant agent.
|
||||
thread_id: The thread ID.
|
||||
arguments: The kernel arguments.
|
||||
kernel: The kernel.
|
||||
instructions_override: The instructions override.
|
||||
additional_instructions: The additional instructions.
|
||||
additional_messages: The additional messages.
|
||||
max_completion_tokens: The maximum completion tokens.
|
||||
max_prompt_tokens: The maximum prompt tokens.
|
||||
messages: The messages that act as a receiver for completed messages.
|
||||
metadata: The metadata.
|
||||
model: The model.
|
||||
output_messages: The output messages received from the agent. These are full content messages
|
||||
formed from the streamed chunks.
|
||||
parallel_tool_calls: The parallel tool calls.
|
||||
reasoning_effort: The reasoning effort.
|
||||
response_format: The response format.
|
||||
tools: The SDK-level tools (e.g. CodeInterpreter, FileSearch). When provided,
|
||||
overrides the tools from the agent definition. Does not affect kernel function availability;
|
||||
use function_choice_behavior for that.
|
||||
temperature: The temperature.
|
||||
top_p: The top p.
|
||||
truncation_strategy: The truncation strategy.
|
||||
function_choice_behavior: Controls which kernel functions are allowed to execute during this run.
|
||||
Use FunctionChoiceBehavior.Auto(filters={"included_functions": [...]}) to restrict to specific
|
||||
functions. Only Auto is supported; other types will raise an error.
|
||||
kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
An async iterable of StreamingChatMessageContent.
|
||||
"""
|
||||
arguments = KernelArguments() if arguments is None else KernelArguments(**arguments, **kwargs)
|
||||
kernel = kernel or agent.kernel
|
||||
|
||||
cls._validate_function_choice_behavior(function_choice_behavior)
|
||||
|
||||
tools = cls._get_tools(
|
||||
agent=agent, kernel=kernel, tools_override=tools, function_choice_behavior=function_choice_behavior
|
||||
) # type: ignore
|
||||
|
||||
base_instructions = await agent.format_instructions(kernel=kernel, arguments=arguments)
|
||||
|
||||
merged_instructions: str = ""
|
||||
if instructions_override is not None:
|
||||
merged_instructions = instructions_override
|
||||
elif base_instructions and additional_instructions:
|
||||
merged_instructions = f"{base_instructions}\n\n{additional_instructions}"
|
||||
else:
|
||||
merged_instructions = base_instructions or additional_instructions or ""
|
||||
|
||||
# form run options
|
||||
run_options = cls._generate_options(
|
||||
agent=agent,
|
||||
model=model,
|
||||
response_format=response_format,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
metadata=metadata,
|
||||
parallel_tool_calls_enabled=parallel_tool_calls,
|
||||
truncation_message_count=truncation_strategy,
|
||||
max_completion_tokens=max_completion_tokens,
|
||||
max_prompt_tokens=max_prompt_tokens,
|
||||
additional_messages=additional_messages,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
run_options = {k: v for k, v in run_options.items() if v is not None}
|
||||
|
||||
stream = agent.client.beta.threads.runs.stream(
|
||||
assistant_id=agent.id,
|
||||
thread_id=thread_id,
|
||||
instructions=merged_instructions or agent.instructions,
|
||||
tools=tools, # type: ignore
|
||||
**run_options,
|
||||
)
|
||||
|
||||
function_steps: dict[str, "FunctionCallContent"] = {}
|
||||
active_messages: dict[str, RunStep] = {}
|
||||
|
||||
while True:
|
||||
async with stream as response_stream:
|
||||
async for event in response_stream:
|
||||
if event.event == "thread.run.created":
|
||||
run = event.data
|
||||
logger.info(f"Assistant run created with ID: {run.id}")
|
||||
elif event.event == "thread.run.in_progress":
|
||||
run = event.data
|
||||
logger.info(f"Assistant run in progress with ID: {run.id}")
|
||||
elif event.event == "thread.message.delta":
|
||||
content = generate_streaming_message_content(agent.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 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 isinstance(details, ToolCallDeltaObject) and details.tool_calls:
|
||||
for tool_call in details.tool_calls:
|
||||
tool_content = None
|
||||
content_is_visible = False
|
||||
# Function Calling-related content is emitted as a single message
|
||||
# via the `on_intermediate_message` callback.
|
||||
if tool_call.type == "code_interpreter":
|
||||
tool_content = generate_streaming_code_interpreter_content(agent.name, step_details)
|
||||
content_is_visible = True
|
||||
if tool_content:
|
||||
if output_messages is not None and not content_is_visible:
|
||||
output_messages.append(tool_content)
|
||||
if content_is_visible:
|
||||
yield tool_content
|
||||
elif event.event == "thread.run.requires_action":
|
||||
run = event.data
|
||||
action_result = await cls._handle_streaming_requires_action(
|
||||
agent.name,
|
||||
kernel,
|
||||
run,
|
||||
function_steps,
|
||||
arguments,
|
||||
function_choice_behavior=function_choice_behavior,
|
||||
)
|
||||
if action_result is None:
|
||||
raise AgentInvokeException(
|
||||
f"Function call required but no function steps found for agent `{agent.name}` "
|
||||
f"thread: {thread_id}."
|
||||
)
|
||||
for content in (
|
||||
action_result.function_call_streaming_content,
|
||||
action_result.function_result_streaming_content,
|
||||
):
|
||||
if content and output_messages is not None:
|
||||
output_messages.append(content)
|
||||
|
||||
stream = agent.client.beta.threads.runs.submit_tool_outputs_stream(
|
||||
run_id=run.id,
|
||||
thread_id=thread_id,
|
||||
tool_outputs=action_result.tool_outputs, # type: ignore
|
||||
)
|
||||
break
|
||||
elif event.event == "thread.run.completed":
|
||||
run = event.data
|
||||
logger.info(f"Run completed with ID: {run.id}")
|
||||
if len(active_messages) > 0:
|
||||
for id in active_messages:
|
||||
step: RunStep = active_messages[id]
|
||||
message = await cls._retrieve_message(
|
||||
agent=agent,
|
||||
thread_id=thread_id,
|
||||
message_id=id, # type: ignore
|
||||
)
|
||||
|
||||
if message and message.content:
|
||||
content = generate_final_streaming_message_content(agent.name, message, step)
|
||||
if output_messages is not None:
|
||||
output_messages.append(content)
|
||||
return
|
||||
elif event.event == "thread.run.failed":
|
||||
run = event.data # type: ignore
|
||||
error_message = ""
|
||||
if run.last_error and run.last_error.message:
|
||||
error_message = run.last_error.message
|
||||
raise AgentInvokeException(
|
||||
f"Run failed with status: `{run.status}` for agent `{agent.name}` and thread `{thread_id}` "
|
||||
f"with error: {error_message}"
|
||||
)
|
||||
else:
|
||||
# If the inner loop completes without encountering a 'break', exit the outer loop
|
||||
break
|
||||
|
||||
@classmethod
|
||||
async def _handle_streaming_requires_action(
|
||||
cls: type[_T],
|
||||
agent_name: str,
|
||||
kernel: "Kernel",
|
||||
run: "Run",
|
||||
function_steps: dict[str, "FunctionCallContent"],
|
||||
arguments: KernelArguments,
|
||||
function_choice_behavior: FunctionChoiceBehavior | None = None,
|
||||
**kwargs: Any,
|
||||
) -> FunctionActionResult | None:
|
||||
"""Handle the requires action event for a streaming run."""
|
||||
fccs = get_function_call_contents(run, function_steps)
|
||||
if fccs:
|
||||
function_call_streaming_content = generate_function_call_streaming_content(agent_name=agent_name, fccs=fccs)
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
|
||||
chat_history = ChatHistory() if kwargs.get("chat_history") is None else kwargs["chat_history"]
|
||||
results = await cls._invoke_function_calls(
|
||||
kernel=kernel,
|
||||
fccs=fccs,
|
||||
chat_history=chat_history,
|
||||
arguments=arguments,
|
||||
function_choice_behavior=function_choice_behavior,
|
||||
)
|
||||
|
||||
function_result_streaming_content = merge_streaming_function_results(
|
||||
messages=chat_history.messages[-len(results) :],
|
||||
name=agent_name,
|
||||
)
|
||||
tool_outputs = cls._format_tool_outputs(fccs, chat_history)
|
||||
return FunctionActionResult(
|
||||
function_call_streaming_content,
|
||||
function_result_streaming_content,
|
||||
tool_outputs,
|
||||
)
|
||||
return None
|
||||
|
||||
# endregion
|
||||
|
||||
@classmethod
|
||||
async def get_messages(
|
||||
cls: type[_T],
|
||||
client: AsyncOpenAI,
|
||||
thread_id: str,
|
||||
sort_order: Literal["asc", "desc"] | None = None,
|
||||
) -> AsyncIterable["ChatMessageContent"]:
|
||||
"""Get messages from the thread.
|
||||
|
||||
Args:
|
||||
client: The client to use to get the messages.
|
||||
thread_id: The ID of the thread to get the messages from.
|
||||
sort_order: The sort order of the messages.
|
||||
|
||||
Returns:
|
||||
An async iterable of ChatMessageContent.
|
||||
"""
|
||||
agent_names: dict[str, Any] = {}
|
||||
last_id: str | Omit = omit
|
||||
|
||||
while True:
|
||||
messages = await client.beta.threads.messages.list(
|
||||
thread_id=thread_id,
|
||||
order=sort_order, # type: ignore
|
||||
after=last_id,
|
||||
)
|
||||
|
||||
if not messages:
|
||||
break
|
||||
|
||||
for message in messages.data:
|
||||
last_id = message.id
|
||||
|
||||
if message.assistant_id and message.assistant_id.strip() not in agent_names:
|
||||
agent = await client.beta.assistants.retrieve(message.assistant_id)
|
||||
if agent.name and agent.name.strip():
|
||||
agent_names[agent.id] = agent.name
|
||||
|
||||
assistant_name = agent_names.get(message.assistant_id or "", None) or message.assistant_id or message.id
|
||||
content = generate_message_content(str(assistant_name), message)
|
||||
|
||||
if len(content.items) > 0:
|
||||
yield content
|
||||
|
||||
if not messages.has_more:
|
||||
break
|
||||
|
||||
@classmethod
|
||||
async def _retrieve_message(
|
||||
cls: type[_T], agent: "OpenAIAssistantAgent", thread_id: str, message_id: str
|
||||
) -> "Message | None":
|
||||
"""Retrieve a message from a thread."""
|
||||
message: "Message | None" = None
|
||||
count = 0
|
||||
max_retries = 3
|
||||
while count < max_retries:
|
||||
try:
|
||||
message = await agent.client.beta.threads.messages.retrieve(thread_id=thread_id, message_id=message_id)
|
||||
break
|
||||
except Exception as ex:
|
||||
logger.error(f"Failed to retrieve message {message_id} from thread {thread_id}: {ex}")
|
||||
count += 1
|
||||
if count >= max_retries:
|
||||
logger.error(
|
||||
f"Max retries reached. Unable to retrieve message {message_id} from thread {thread_id}."
|
||||
)
|
||||
break
|
||||
backoff_time: float = agent.polling_options.message_synchronization_delay.total_seconds() * (2**count)
|
||||
await asyncio.sleep(backoff_time)
|
||||
return message
|
||||
|
||||
@classmethod
|
||||
async def _invoke_function_calls(
|
||||
cls: type[_T],
|
||||
kernel: "Kernel",
|
||||
fccs: list["FunctionCallContent"],
|
||||
chat_history: "ChatHistory",
|
||||
arguments: KernelArguments,
|
||||
function_choice_behavior: FunctionChoiceBehavior | None = None,
|
||||
) -> list["AutoFunctionInvocationContext | None"]:
|
||||
"""Invoke the function calls."""
|
||||
return await asyncio.gather(
|
||||
*[
|
||||
kernel.invoke_function_call(
|
||||
function_call=function_call,
|
||||
chat_history=chat_history,
|
||||
arguments=arguments,
|
||||
function_behavior=function_choice_behavior,
|
||||
)
|
||||
for function_call in fccs
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _format_tool_outputs(
|
||||
cls: type[_T], fccs: list["FunctionCallContent"], chat_history: "ChatHistory"
|
||||
) -> list[dict[str, str]]:
|
||||
"""Format the tool outputs for submission."""
|
||||
from semantic_kernel.contents.function_result_content import FunctionResultContent
|
||||
|
||||
tool_call_lookup = {
|
||||
tool_call.id: tool_call
|
||||
for message in chat_history.messages
|
||||
for tool_call in message.items
|
||||
if isinstance(tool_call, FunctionResultContent) and tool_call.id is not None
|
||||
}
|
||||
return [
|
||||
{"tool_call_id": fcc.id, "output": str(tool_call_lookup[fcc.id].result)}
|
||||
for fcc in fccs
|
||||
if fcc.id in tool_call_lookup
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def _poll_run_status(
|
||||
cls: type[_T], agent: "OpenAIAssistantAgent", run: "Run", thread_id: str, polling_options: RunPollingOptions
|
||||
) -> "Run":
|
||||
"""Poll the run status."""
|
||||
logger.info(f"Polling run status: {run.id}, threadId: {thread_id}")
|
||||
|
||||
try:
|
||||
run = await asyncio.wait_for(
|
||||
cls._poll_loop(agent, run, thread_id, polling_options),
|
||||
timeout=polling_options.run_polling_timeout.total_seconds(),
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
timeout_duration = polling_options.run_polling_timeout
|
||||
error_message = f"Polling timed out for run id: `{run.id}` and thread id: `{thread_id}` after waiting {timeout_duration}." # noqa: E501
|
||||
logger.error(error_message)
|
||||
raise AgentInvokeException(error_message)
|
||||
|
||||
logger.info(f"Polled run status: {run.status}, {run.id}, threadId: {thread_id}")
|
||||
return run
|
||||
|
||||
@classmethod
|
||||
async def _poll_loop(
|
||||
cls: type[_T], agent: "OpenAIAssistantAgent", run: "Run", thread_id: str, polling_options: RunPollingOptions
|
||||
) -> "Run":
|
||||
"""Internal polling loop."""
|
||||
count = 0
|
||||
while True:
|
||||
await asyncio.sleep(polling_options.get_polling_interval(count).total_seconds())
|
||||
count += 1
|
||||
|
||||
try:
|
||||
run = await agent.client.beta.threads.runs.retrieve(run.id, thread_id=thread_id)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to retrieve run for run id: `{run.id}` and thread id: `{thread_id}`: {e}")
|
||||
# Retry anyway
|
||||
|
||||
if run.status not in cls.polling_status:
|
||||
break
|
||||
|
||||
return run
|
||||
|
||||
@classmethod
|
||||
def _merge_options(
|
||||
cls: type[_T],
|
||||
*,
|
||||
agent: "OpenAIAssistantAgent",
|
||||
model: str | None = None,
|
||||
response_format: "AssistantResponseFormatOptionParam | None" = None,
|
||||
temperature: float | None = None,
|
||||
top_p: float | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Merge run-time options with the agent-level options.
|
||||
|
||||
Run-level parameters take precedence.
|
||||
"""
|
||||
return {
|
||||
"model": model if model is not None else agent.definition.model,
|
||||
"response_format": response_format if response_format is not None else None,
|
||||
"temperature": temperature if temperature is not None else agent.definition.temperature,
|
||||
"top_p": top_p if top_p is not None else agent.definition.top_p,
|
||||
"metadata": metadata if metadata is not None else agent.definition.metadata,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _generate_options(cls: type[_T], **kwargs: Any) -> dict[str, Any]:
|
||||
"""Generate a dictionary of options that can be passed directly to create_run."""
|
||||
merged = cls._merge_options(**kwargs)
|
||||
agent = kwargs.get("agent")
|
||||
trunc_count = merged.get("truncation_message_count", None)
|
||||
max_completion_tokens = merged.get("max_completion_tokens", None)
|
||||
max_prompt_tokens = merged.get("max_prompt_tokens", None)
|
||||
parallel_tool_calls = merged.get("parallel_tool_calls_enabled", None)
|
||||
additional_messages = cls._translate_additional_messages(agent, merged.get("additional_messages", None))
|
||||
return {
|
||||
"model": merged.get("model"),
|
||||
"top_p": merged.get("top_p"),
|
||||
"response_format": merged.get("response_format"),
|
||||
"temperature": merged.get("temperature"),
|
||||
"truncation_strategy": trunc_count,
|
||||
"metadata": merged.get("metadata"),
|
||||
"max_completion_tokens": max_completion_tokens,
|
||||
"max_prompt_tokens": max_prompt_tokens,
|
||||
"parallel_tool_calls": parallel_tool_calls,
|
||||
"additional_messages": additional_messages,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _translate_additional_messages(
|
||||
cls: type[_T], agent, messages: "list[ChatMessageContent] | None"
|
||||
) -> list[AdditionalMessage] | None:
|
||||
"""Translate additional messages to the required format."""
|
||||
if not messages:
|
||||
return None
|
||||
return cls._form_additional_messages(messages)
|
||||
|
||||
@classmethod
|
||||
def _form_additional_messages(
|
||||
cls: type[_T], messages: list["ChatMessageContent"]
|
||||
) -> list[AdditionalMessage] | None:
|
||||
"""Form the additional messages for the specified thread."""
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
additional_messages = []
|
||||
for message in messages:
|
||||
if not message.content:
|
||||
continue
|
||||
|
||||
message_with_all: AdditionalMessage = {
|
||||
"content": message.content,
|
||||
"role": "assistant" if message.role == AuthorRole.ASSISTANT else "user",
|
||||
"attachments": cls._get_attachments(message) if message.items else None,
|
||||
"metadata": cls._get_metadata(message) if message.metadata else None,
|
||||
}
|
||||
additional_messages.append(message_with_all)
|
||||
return additional_messages
|
||||
|
||||
@classmethod
|
||||
def _get_attachments(cls: type[_T], message: "ChatMessageContent") -> list[AdditionalMessageAttachment]:
|
||||
return [
|
||||
AdditionalMessageAttachment(
|
||||
file_id=file_content.file_id,
|
||||
tools=list(cls._get_tool_definition(file_content.tools)), # type: ignore
|
||||
data_source=file_content.data_source if file_content.data_source else None,
|
||||
)
|
||||
for file_content in message.items
|
||||
if isinstance(file_content, (FileReferenceContent, StreamingFileReferenceContent))
|
||||
and file_content.file_id is not None
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _get_metadata(cls: type[_T], message: "ChatMessageContent") -> dict[str, str]:
|
||||
"""Get the metadata for an agent message."""
|
||||
return {k: str(v) if v is not None else "" for k, v in (message.metadata or {}).items()}
|
||||
|
||||
@classmethod
|
||||
def _get_tool_definition(cls: type[_T], tools: list[Any]) -> Iterable["AdditionalMessageAttachmentTool"]:
|
||||
if not tools:
|
||||
return
|
||||
for tool in tools:
|
||||
if tool_definition := cls.tool_metadata.get(tool):
|
||||
yield from tool_definition
|
||||
|
||||
@staticmethod
|
||||
def _validate_function_choice_behavior(
|
||||
function_choice_behavior: FunctionChoiceBehavior | None,
|
||||
) -> None:
|
||||
"""Validate the function choice behavior is compatible with agent invocations."""
|
||||
if function_choice_behavior is None:
|
||||
return
|
||||
if function_choice_behavior.type_ != FunctionChoiceType.AUTO:
|
||||
raise AgentInvokeException(
|
||||
f"FunctionChoiceBehavior with type '{function_choice_behavior.type_}' is not supported for agent "
|
||||
"invocations. Use FunctionChoiceBehavior.Auto(filters=...) to control which kernel functions "
|
||||
"are available."
|
||||
)
|
||||
if not function_choice_behavior.auto_invoke_kernel_functions:
|
||||
raise AgentInvokeException(
|
||||
"FunctionChoiceBehavior.Auto(auto_invoke=False) is not supported for agent invocations. "
|
||||
"The agent run loop manages tool invocation; disabling auto_invoke is not compatible."
|
||||
)
|
||||
valid_filter_keys: set[str] = {
|
||||
"excluded_plugins",
|
||||
"included_plugins",
|
||||
"excluded_functions",
|
||||
"included_functions",
|
||||
}
|
||||
if function_choice_behavior.filters is not None:
|
||||
if not function_choice_behavior.filters:
|
||||
raise AgentInvokeException(
|
||||
"FunctionChoiceBehavior filters must not be empty. Provide at least one filter key "
|
||||
f"from {sorted(valid_filter_keys)}, or omit filters entirely to include all "
|
||||
"kernel functions."
|
||||
)
|
||||
unknown_keys = {str(k) for k in function_choice_behavior.filters} - valid_filter_keys
|
||||
if unknown_keys:
|
||||
raise AgentInvokeException(
|
||||
f"Unknown filter key(s): {sorted(unknown_keys)}. "
|
||||
f"Valid filter keys are: {sorted(valid_filter_keys)}."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_tools(
|
||||
cls: type[_T],
|
||||
agent: "OpenAIAssistantAgent",
|
||||
kernel: "Kernel",
|
||||
tools_override: "list[AssistantToolParam] | None" = None,
|
||||
function_choice_behavior: FunctionChoiceBehavior | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Get the list of tools for the assistant.
|
||||
|
||||
Args:
|
||||
agent: The assistant agent.
|
||||
kernel: The kernel to use for function metadata.
|
||||
tools_override: When provided, overrides agent.definition.tools (SDK-level tools only).
|
||||
function_choice_behavior: When provided, filters which kernel functions are included.
|
||||
|
||||
Returns:
|
||||
The list of tools.
|
||||
"""
|
||||
tools: list[Any] = []
|
||||
|
||||
source_tools = tools_override if tools_override is not None else agent.definition.tools
|
||||
for tool in source_tools:
|
||||
if isinstance(tool, CodeInterpreterTool):
|
||||
tools.append({"type": "code_interpreter"})
|
||||
elif isinstance(tool, FileSearchTool):
|
||||
tools.append({"type": "file_search"})
|
||||
|
||||
# Determine kernel function metadata based on function_choice_behavior
|
||||
if function_choice_behavior is not None and not function_choice_behavior.enable_kernel_functions:
|
||||
funcs = []
|
||||
elif function_choice_behavior is not None and function_choice_behavior.filters:
|
||||
funcs = kernel.get_list_of_function_metadata(function_choice_behavior.filters)
|
||||
else:
|
||||
funcs = kernel.get_full_list_of_function_metadata()
|
||||
|
||||
tools.extend([kernel_function_metadata_to_function_call_format(f) for f in funcs])
|
||||
|
||||
return tools
|
||||
@@ -0,0 +1,286 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable
|
||||
from copy import copy
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from azure.core.credentials import TokenCredential
|
||||
from openai import AsyncAzureOpenAI
|
||||
from pydantic import ValidationError
|
||||
|
||||
from semantic_kernel.agents import OpenAIAssistantAgent
|
||||
from semantic_kernel.agents.agent import register_agent_type
|
||||
from semantic_kernel.connectors.ai.open_ai.settings.azure_open_ai_settings import AzureOpenAISettings
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentInitializationException
|
||||
from semantic_kernel.utils.authentication.entra_id_authentication import get_entra_auth_token
|
||||
from semantic_kernel.utils.feature_stage_decorator import release_candidate
|
||||
from semantic_kernel.utils.telemetry.user_agent import APP_INFO, prepend_semantic_kernel_to_user_agent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseSettings
|
||||
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # pragma: no cover
|
||||
|
||||
if sys.version < "3.11":
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
else:
|
||||
from typing import Self # type: ignore # pragma: no cover
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from warnings import deprecated
|
||||
else:
|
||||
from typing_extensions import deprecated
|
||||
|
||||
|
||||
@release_candidate
|
||||
@register_agent_type("azure_assistant")
|
||||
class AzureAssistantAgent(OpenAIAssistantAgent):
|
||||
"""An Azure Assistant Agent class that extends the OpenAI Assistant Agent class."""
|
||||
|
||||
@staticmethod
|
||||
@deprecated(
|
||||
"setup_resources is deprecated. Use AzureAssistantAgent.create_client() instead. This method will be removed by 2025-06-15." # noqa: E501
|
||||
)
|
||||
def setup_resources(
|
||||
*,
|
||||
ad_token: str | None = None,
|
||||
ad_token_provider: Callable[[], str | Awaitable[str]] | None = None,
|
||||
api_key: str | None = None,
|
||||
api_version: str | None = None,
|
||||
base_url: str | None = None,
|
||||
default_headers: dict[str, str] | None = None,
|
||||
deployment_name: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
token_scope: str | None = None,
|
||||
credential: TokenCredential | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[AsyncAzureOpenAI, str]:
|
||||
"""A method to create the Azure OpenAI client and the deployment name/model from the provided arguments.
|
||||
|
||||
Any arguments provided will override the values in the environment variables/environment file.
|
||||
|
||||
Args:
|
||||
ad_token: The Microsoft Entra (previously Azure AD) token represented as a string
|
||||
ad_token_provider: The Microsoft Entra (previously Azure AD) token provider provided as a callback
|
||||
api_key: The API key
|
||||
api_version: The API version
|
||||
base_url: The base URL in the form https://<resource>.azure.openai.com/openai/deployments/<deployment_name>
|
||||
default_headers: The default headers to add to the client
|
||||
deployment_name: The deployment name
|
||||
endpoint: The endpoint in the form https://<resource>.azure.openai.com
|
||||
env_file_path: The environment file path
|
||||
env_file_encoding: The environment file encoding, defaults to utf-8
|
||||
token_scope: The token scope
|
||||
credential: The credential to use for authentication.
|
||||
kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
An Azure OpenAI client instance and the configured deployment name (model)
|
||||
"""
|
||||
try:
|
||||
azure_openai_settings = AzureOpenAISettings(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
endpoint=endpoint,
|
||||
chat_deployment_name=deployment_name,
|
||||
api_version=api_version,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
token_endpoint=token_scope,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise AgentInitializationException(f"Failed to create Azure OpenAI settings: {exc}") from exc
|
||||
|
||||
if (
|
||||
azure_openai_settings.api_key is None
|
||||
and ad_token_provider is None
|
||||
and ad_token is None
|
||||
and azure_openai_settings.token_endpoint
|
||||
and credential
|
||||
):
|
||||
ad_token = get_entra_auth_token(credential, azure_openai_settings.token_endpoint)
|
||||
|
||||
# If we still have no credentials, we can't proceed
|
||||
if not azure_openai_settings.api_key and not ad_token and not ad_token_provider and not credential:
|
||||
raise AgentInitializationException(
|
||||
"Please provide either an api_key, ad_token, ad_token_provider or credential for authentication."
|
||||
)
|
||||
|
||||
merged_headers = dict(copy(default_headers)) if default_headers else {}
|
||||
if default_headers:
|
||||
merged_headers.update(default_headers)
|
||||
if APP_INFO:
|
||||
merged_headers.update(APP_INFO)
|
||||
merged_headers = prepend_semantic_kernel_to_user_agent(merged_headers)
|
||||
|
||||
if not azure_openai_settings.endpoint:
|
||||
raise AgentInitializationException("Please provide an Azure OpenAI endpoint")
|
||||
|
||||
if not azure_openai_settings.chat_deployment_name:
|
||||
raise AgentInitializationException("Please provide an Azure OpenAI deployment name")
|
||||
|
||||
client = AsyncAzureOpenAI(
|
||||
azure_endpoint=str(azure_openai_settings.endpoint),
|
||||
api_version=azure_openai_settings.api_version,
|
||||
api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None,
|
||||
azure_ad_token=ad_token,
|
||||
azure_ad_token_provider=ad_token_provider,
|
||||
default_headers=merged_headers,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return client, azure_openai_settings.chat_deployment_name
|
||||
|
||||
@staticmethod
|
||||
def create_client(
|
||||
*,
|
||||
ad_token: str | None = None,
|
||||
ad_token_provider: Callable[[], str | Awaitable[str]] | None = None,
|
||||
api_key: str | None = None,
|
||||
api_version: str | None = None,
|
||||
base_url: str | None = None,
|
||||
default_headers: dict[str, str] | None = None,
|
||||
deployment_name: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
token_scope: str | None = None,
|
||||
credential: TokenCredential | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncAzureOpenAI:
|
||||
"""A method to create the Azure OpenAI client.
|
||||
|
||||
Any arguments provided will override the values in the environment variables/environment file.
|
||||
|
||||
Args:
|
||||
ad_token: The Microsoft Entra (previously Azure AD) token represented as a string
|
||||
ad_token_provider: The Microsoft Entra (previously Azure AD) token provider provided as a callback
|
||||
api_key: The API key
|
||||
api_version: The API version
|
||||
base_url: The base URL in the form https://<resource>.azure.openai.com/openai/deployments/<deployment_name>
|
||||
default_headers: The default headers to add to the client
|
||||
deployment_name: The deployment name
|
||||
endpoint: The endpoint in the form https://<resource>.azure.openai.com
|
||||
env_file_path: The environment file path
|
||||
env_file_encoding: The environment file encoding, defaults to utf-8
|
||||
token_scope: The token scope
|
||||
credential: The credential to use for authentication.
|
||||
kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
An Azure OpenAI client instance.
|
||||
"""
|
||||
try:
|
||||
azure_openai_settings = AzureOpenAISettings(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
endpoint=endpoint,
|
||||
chat_deployment_name=deployment_name,
|
||||
api_version=api_version,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
token_endpoint=token_scope,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise AgentInitializationException(f"Failed to create Azure OpenAI settings: {exc}") from exc
|
||||
|
||||
if (
|
||||
azure_openai_settings.api_key is None
|
||||
and ad_token_provider is None
|
||||
and ad_token is None
|
||||
and azure_openai_settings.token_endpoint
|
||||
and credential
|
||||
):
|
||||
ad_token = get_entra_auth_token(credential, azure_openai_settings.token_endpoint)
|
||||
|
||||
# If we still have no credentials, we can't proceed
|
||||
if not azure_openai_settings.api_key and not ad_token and not ad_token_provider and not credential:
|
||||
raise AgentInitializationException(
|
||||
"Please provide either an api_key, ad_token, ad_token_provider or credential for authentication."
|
||||
)
|
||||
|
||||
merged_headers = dict(copy(default_headers)) if default_headers else {}
|
||||
if default_headers:
|
||||
merged_headers.update(default_headers)
|
||||
if APP_INFO:
|
||||
merged_headers.update(APP_INFO)
|
||||
merged_headers = prepend_semantic_kernel_to_user_agent(merged_headers)
|
||||
|
||||
if not azure_openai_settings.endpoint:
|
||||
raise AgentInitializationException("Please provide an Azure OpenAI endpoint")
|
||||
|
||||
if not azure_openai_settings.chat_deployment_name:
|
||||
raise AgentInitializationException("Please provide an Azure OpenAI deployment name")
|
||||
|
||||
return AsyncAzureOpenAI(
|
||||
azure_endpoint=str(azure_openai_settings.endpoint),
|
||||
api_version=azure_openai_settings.api_version,
|
||||
api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None,
|
||||
azure_ad_token=ad_token,
|
||||
azure_ad_token_provider=ad_token_provider,
|
||||
default_headers=merged_headers,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def resolve_placeholders(
|
||||
cls: type[Self],
|
||||
yaml_str: str,
|
||||
settings: "KernelBaseSettings | None" = None,
|
||||
extras: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Substitute ${AzureOpenAI:Key} placeholders with fields from AzureOpenAIAgentSettings and extras."""
|
||||
import re
|
||||
|
||||
pattern = re.compile(r"\$\{([^}]+)\}")
|
||||
|
||||
# Build the mapping only if settings is provided and valid
|
||||
field_mapping: dict[str, Any] = {}
|
||||
|
||||
if settings is None:
|
||||
settings = AzureOpenAISettings()
|
||||
|
||||
if not isinstance(settings, AzureOpenAISettings):
|
||||
raise AgentInitializationException(f"Expected AzureOpenAISettings, got {type(settings).__name__}")
|
||||
|
||||
field_mapping.update({
|
||||
"ChatModelId": cls._get_setting(getattr(settings, "chat_deployment_name", None)),
|
||||
"AgentId": cls._get_setting(getattr(settings, "agent_id", None)),
|
||||
"ApiKey": cls._get_setting(getattr(settings, "api_key", None)),
|
||||
"ApiVersion": cls._get_setting(getattr(settings, "api_version", None)),
|
||||
"BaseUrl": cls._get_setting(getattr(settings, "base_url", None)),
|
||||
"Endpoint": cls._get_setting(getattr(settings, "endpoint", None)),
|
||||
"TokenEndpoint": cls._get_setting(getattr(settings, "token_endpoint", None)),
|
||||
})
|
||||
|
||||
if extras:
|
||||
field_mapping.update(extras)
|
||||
|
||||
def replacer(match: re.Match[str]) -> str:
|
||||
"""Replace the matched placeholder with the corresponding value from field_mapping."""
|
||||
full_key = match.group(1) # for example, OpenAI:ApiKey
|
||||
section, _, key = full_key.partition(":")
|
||||
if section != "AzureOpenAI":
|
||||
return match.group(0)
|
||||
|
||||
# Try short key first (ApiKey), then full (OpenAI:ApiKey)
|
||||
return str(field_mapping.get(key) or field_mapping.get(full_key) or match.group(0))
|
||||
|
||||
result = pattern.sub(replacer, yaml_str)
|
||||
|
||||
# Safety check for unresolved placeholders
|
||||
unresolved = pattern.findall(result)
|
||||
if unresolved:
|
||||
raise AgentInitializationException(
|
||||
f"Unresolved placeholders in spec: {', '.join(f'${{{key}}}' for key in unresolved)}"
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,293 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable
|
||||
from copy import copy
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from azure.core.credentials import TokenCredential
|
||||
from openai import AsyncAzureOpenAI
|
||||
from pydantic import ValidationError
|
||||
|
||||
from semantic_kernel.agents import OpenAIResponsesAgent
|
||||
from semantic_kernel.agents.agent import register_agent_type
|
||||
from semantic_kernel.connectors.ai.open_ai.settings.azure_open_ai_settings import AzureOpenAISettings
|
||||
from semantic_kernel.exceptions.agent_exceptions import (
|
||||
AgentInitializationException,
|
||||
)
|
||||
from semantic_kernel.utils.authentication.entra_id_authentication import get_entra_auth_token
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
from semantic_kernel.utils.telemetry.user_agent import APP_INFO, prepend_semantic_kernel_to_user_agent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseSettings
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # pragma: no cover
|
||||
|
||||
if sys.version < "3.11":
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
else:
|
||||
from typing import Self # type: ignore # pragma: no cover
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from warnings import deprecated
|
||||
else:
|
||||
from typing_extensions import deprecated
|
||||
|
||||
|
||||
@experimental
|
||||
@register_agent_type("azure_responses")
|
||||
class AzureResponsesAgent(OpenAIResponsesAgent):
|
||||
"""Azure Responses Agent class.
|
||||
|
||||
Provides the ability to interact with Azure's Responses API.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@deprecated(
|
||||
"setup_resources is deprecated. Use AzureResponsesAgent.create_client() instead. This method will be removed by 2025-06-15." # noqa: E501
|
||||
)
|
||||
def setup_resources(
|
||||
*,
|
||||
ad_token: str | None = None,
|
||||
ad_token_provider: Callable[[], str | Awaitable[str]] | None = None,
|
||||
api_key: str | None = None,
|
||||
api_version: str | None = None,
|
||||
base_url: str | None = None,
|
||||
default_headers: dict[str, str] | None = None,
|
||||
deployment_name: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
token_scope: str | None = None,
|
||||
credential: TokenCredential | None = None,
|
||||
**kwargs: Any,
|
||||
) -> tuple[AsyncAzureOpenAI, str]:
|
||||
"""A method to create the Azure OpenAI client and the deployment name/model from the provided arguments.
|
||||
|
||||
Any arguments provided will override the values in the environment variables/environment file.
|
||||
|
||||
Args:
|
||||
ad_token: The Microsoft Entra (previously Azure AD) token represented as a string
|
||||
ad_token_provider: The Microsoft Entra (previously Azure AD) token provider provided as a callback
|
||||
api_key: The API key
|
||||
api_version: The API version
|
||||
base_url: The base URL in the form https://<resource>.azure.openai.com/openai/deployments/<deployment_name>
|
||||
default_headers: The default headers to add to the client
|
||||
deployment_name: The Responses deployment name
|
||||
endpoint: The endpoint in the form https://<resource>.azure.openai.com
|
||||
env_file_path: The environment file path
|
||||
env_file_encoding: The environment file encoding, defaults to utf-8
|
||||
token_scope: The token scope
|
||||
credential: The credential to use for authentication.
|
||||
kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
An Azure OpenAI client instance and the configured deployment name (model)
|
||||
"""
|
||||
try:
|
||||
azure_openai_settings = AzureOpenAISettings(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
endpoint=endpoint,
|
||||
responses_deployment_name=deployment_name,
|
||||
api_version=api_version,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
token_endpoint=token_scope,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise AgentInitializationException(f"Failed to create Azure OpenAI settings: {exc}") from exc
|
||||
|
||||
if (
|
||||
azure_openai_settings.api_key is None
|
||||
and ad_token_provider is None
|
||||
and ad_token is None
|
||||
and azure_openai_settings.token_endpoint
|
||||
and credential
|
||||
):
|
||||
ad_token = get_entra_auth_token(credential, azure_openai_settings.token_endpoint)
|
||||
|
||||
# If we still have no credentials, we can't proceed
|
||||
if not azure_openai_settings.api_key and not ad_token and not ad_token_provider and not credential:
|
||||
raise AgentInitializationException(
|
||||
"Please provide either an api_key, ad_token, ad_token_provider or credential for authentication."
|
||||
)
|
||||
|
||||
merged_headers = dict(copy(default_headers)) if default_headers else {}
|
||||
if default_headers:
|
||||
merged_headers.update(default_headers)
|
||||
if APP_INFO:
|
||||
merged_headers.update(APP_INFO)
|
||||
merged_headers = prepend_semantic_kernel_to_user_agent(merged_headers)
|
||||
|
||||
if not azure_openai_settings.endpoint:
|
||||
raise AgentInitializationException("Please provide an Azure OpenAI endpoint")
|
||||
|
||||
if not azure_openai_settings.responses_deployment_name:
|
||||
raise AgentInitializationException("Please provide an Azure OpenAI Responses deployment name")
|
||||
|
||||
client = AsyncAzureOpenAI(
|
||||
azure_endpoint=str(azure_openai_settings.endpoint),
|
||||
api_version=azure_openai_settings.api_version,
|
||||
api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None,
|
||||
azure_ad_token=ad_token,
|
||||
azure_ad_token_provider=ad_token_provider,
|
||||
default_headers=merged_headers,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return client, azure_openai_settings.responses_deployment_name
|
||||
|
||||
@staticmethod
|
||||
def create_client(
|
||||
*,
|
||||
ad_token: str | None = None,
|
||||
ad_token_provider: Callable[[], str | Awaitable[str]] | None = None,
|
||||
api_key: str | None = None,
|
||||
api_version: str | None = None,
|
||||
base_url: str | None = None,
|
||||
default_headers: dict[str, str] | None = None,
|
||||
deployment_name: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
token_scope: str | None = None,
|
||||
credential: TokenCredential | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncAzureOpenAI:
|
||||
"""A method to create the Azure OpenAI client.
|
||||
|
||||
Any arguments provided will override the values in the environment variables/environment file.
|
||||
|
||||
Args:
|
||||
ad_token: The Microsoft Entra (previously Azure AD) token represented as a string
|
||||
ad_token_provider: The Microsoft Entra (previously Azure AD) token provider provided as a callback
|
||||
api_key: The API key
|
||||
api_version: The API version
|
||||
base_url: The base URL in the form https://<resource>.azure.openai.com/openai/deployments/<deployment_name>
|
||||
default_headers: The default headers to add to the client
|
||||
deployment_name: The Responses deployment name
|
||||
endpoint: The endpoint in the form https://<resource>.azure.openai.com
|
||||
env_file_path: The environment file path
|
||||
env_file_encoding: The environment file encoding, defaults to utf-8
|
||||
token_scope: The token scope
|
||||
credential: The credential to use for authentication.
|
||||
kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
An Azure OpenAI client instance.
|
||||
"""
|
||||
try:
|
||||
azure_openai_settings = AzureOpenAISettings(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
endpoint=endpoint,
|
||||
responses_deployment_name=deployment_name,
|
||||
api_version=api_version,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
token_endpoint=token_scope,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise AgentInitializationException(f"Failed to create Azure OpenAI settings: {exc}") from exc
|
||||
|
||||
if (
|
||||
azure_openai_settings.api_key is None
|
||||
and ad_token_provider is None
|
||||
and ad_token is None
|
||||
and azure_openai_settings.token_endpoint
|
||||
and credential
|
||||
):
|
||||
ad_token = get_entra_auth_token(credential, azure_openai_settings.token_endpoint)
|
||||
|
||||
# If we still have no credentials, we can't proceed
|
||||
if not azure_openai_settings.api_key and not ad_token and not ad_token_provider and not credential:
|
||||
raise AgentInitializationException(
|
||||
"Please provide either an api_key, ad_token, ad_token_provider or credential for authentication."
|
||||
)
|
||||
|
||||
merged_headers = dict(copy(default_headers)) if default_headers else {}
|
||||
if default_headers:
|
||||
merged_headers.update(default_headers)
|
||||
if APP_INFO:
|
||||
merged_headers.update(APP_INFO)
|
||||
merged_headers = prepend_semantic_kernel_to_user_agent(merged_headers)
|
||||
|
||||
if not azure_openai_settings.endpoint:
|
||||
raise AgentInitializationException("Please provide an Azure OpenAI endpoint")
|
||||
|
||||
if not azure_openai_settings.responses_deployment_name:
|
||||
raise AgentInitializationException("Please provide an Azure OpenAI Responses deployment name")
|
||||
|
||||
return AsyncAzureOpenAI(
|
||||
azure_endpoint=str(azure_openai_settings.endpoint),
|
||||
api_version=azure_openai_settings.api_version,
|
||||
api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None,
|
||||
azure_ad_token=ad_token,
|
||||
azure_ad_token_provider=ad_token_provider,
|
||||
default_headers=merged_headers,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def resolve_placeholders(
|
||||
cls: type[Self],
|
||||
yaml_str: str,
|
||||
settings: "KernelBaseSettings | None" = None,
|
||||
extras: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Substitute ${AzureOpenAI:Key} placeholders with fields from AzureOpenAIAgentSettings and extras."""
|
||||
import re
|
||||
|
||||
pattern = re.compile(r"\$\{([^}]+)\}")
|
||||
|
||||
# Build the mapping only if settings is provided and valid
|
||||
field_mapping: dict[str, Any] = {}
|
||||
|
||||
if settings is None:
|
||||
settings = AzureOpenAISettings()
|
||||
|
||||
if not isinstance(settings, AzureOpenAISettings):
|
||||
raise AgentInitializationException(f"Expected AzureOpenAISettings, got {type(settings).__name__}")
|
||||
|
||||
field_mapping.update({
|
||||
"ChatModelId": getattr(settings, "responses_deployment_name", None),
|
||||
"AgentId": getattr(settings, "agent_id", None),
|
||||
"ApiKey": getattr(settings, "api_key", None),
|
||||
"ApiVersion": getattr(settings, "api_version", None),
|
||||
"BaseUrl": getattr(settings, "base_url", None),
|
||||
"Endpoint": getattr(settings, "endpoint", None),
|
||||
"TokenEndpoint": getattr(settings, "token_endpoint", None),
|
||||
})
|
||||
|
||||
if extras:
|
||||
field_mapping.update(extras)
|
||||
|
||||
def replacer(match: re.Match[str]) -> str:
|
||||
"""Replace the matched placeholder with the corresponding value from field_mapping."""
|
||||
full_key = match.group(1) # for example, AzureOpenAI:ApiKey
|
||||
section, _, key = full_key.partition(":")
|
||||
if section != "AzureOpenAI":
|
||||
return match.group(0)
|
||||
|
||||
# Try short key first (ApiKey), then full (AzureOpenAI:ApiKey)
|
||||
return str(field_mapping.get(key) or field_mapping.get(full_key) or match.group(0))
|
||||
|
||||
result = pattern.sub(replacer, yaml_str)
|
||||
|
||||
# Safety check for unresolved placeholders
|
||||
unresolved = pattern.findall(result)
|
||||
if unresolved:
|
||||
raise AgentInitializationException(
|
||||
f"Unresolved placeholders in spec: {', '.join(f'${{{key}}}' for key in unresolved)}"
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@experimental
|
||||
@dataclass
|
||||
class FunctionActionResult:
|
||||
"""Function Action Result."""
|
||||
|
||||
function_call_streaming_content: StreamingChatMessageContent
|
||||
function_result_streaming_content: StreamingChatMessageContent
|
||||
tool_outputs: list[dict[str, str]]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
class RunPollingOptions(KernelBaseModel):
|
||||
"""Configuration and defaults associated with polling behavior for Assistant API requests."""
|
||||
|
||||
default_polling_interval: timedelta = Field(default=timedelta(milliseconds=250))
|
||||
default_polling_backoff: timedelta = Field(default=timedelta(seconds=1))
|
||||
default_polling_backoff_threshold: int = Field(default=2)
|
||||
default_message_synchronization_delay: timedelta = Field(default=timedelta(milliseconds=250))
|
||||
run_polling_interval: timedelta = Field(default=timedelta(milliseconds=250))
|
||||
run_polling_backoff: timedelta = Field(default=timedelta(seconds=1))
|
||||
run_polling_backoff_threshold: int = Field(default=2)
|
||||
message_synchronization_delay: timedelta = Field(default=timedelta(milliseconds=250))
|
||||
run_polling_timeout: timedelta = Field(default=timedelta(minutes=1)) # New timeout attribute
|
||||
|
||||
def get_polling_interval(self, iteration_count: int) -> timedelta:
|
||||
"""Get the polling interval for the given iteration count."""
|
||||
return (
|
||||
self.run_polling_backoff
|
||||
if iteration_count > self.run_polling_backoff_threshold
|
||||
else self.run_polling_interval
|
||||
)
|
||||
Reference in New Issue
Block a user