chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 13:21:23 +08:00
commit b957a53def
5423 changed files with 863745 additions and 0 deletions
@@ -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
)