Files
microsoft--semantic-kernel/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py
T
wehub-resource-sync b957a53def
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:21:23 +08:00

1238 lines
60 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
from collections.abc import AsyncIterable
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar, cast
from azure.ai.agents.models import (
AgentsNamedToolChoiceType,
AgentStreamEvent,
AsyncAgentEventHandler,
AsyncAgentRunStream,
BaseAsyncAgentEventHandler,
FunctionToolDefinition,
RequiredMcpToolCall,
ResponseFormatJsonSchemaType,
RunStep,
RunStepAzureAISearchToolCall,
RunStepBingCustomSearchToolCall,
RunStepBingGroundingToolCall,
RunStepCodeInterpreterToolCall,
RunStepDeepResearchToolCall,
RunStepDeltaChunk,
RunStepDeltaToolCallObject,
RunStepFileSearchToolCall,
RunStepMcpToolCall,
RunStepMessageCreationDetails,
RunStepOpenAPIToolCall,
RunStepToolCallDetails,
RunStepType,
SubmitToolApprovalAction,
SubmitToolOutputsAction,
ThreadMessage,
ThreadRun,
ToolApproval,
ToolDefinition,
TruncationObject,
)
from azure.ai.agents.models._enums import MessageRole
from semantic_kernel.agents.azure_ai.agent_content_generation import (
THREAD_MESSAGE_ID,
generate_azure_ai_search_content,
generate_bing_grounding_content,
generate_code_interpreter_content,
generate_deep_research_content,
generate_file_search_content,
generate_function_call_content,
generate_function_call_streaming_content,
generate_function_result_content,
generate_mcp_call_content,
generate_mcp_content,
generate_message_content,
generate_openapi_content,
generate_streaming_azure_ai_search_content,
generate_streaming_bing_grounding_content,
generate_streaming_code_interpreter_content,
generate_streaming_deep_research_content,
generate_streaming_file_search_content,
generate_streaming_mcp_call_content,
generate_streaming_mcp_content,
generate_streaming_message_content,
generate_streaming_openapi_content,
get_function_call_contents,
)
from semantic_kernel.agents.azure_ai.azure_ai_agent_utils import AzureAIAgentUtils
from semantic_kernel.agents.open_ai.assistant_content_generation import 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.chat_message_content import ChatMessageContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.exceptions.agent_exceptions import AgentInvokeException, AgentThreadOperationException
from semantic_kernel.functions import KernelArguments
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata
from semantic_kernel.utils.feature_stage_decorator import experimental
if TYPE_CHECKING:
from azure.ai.projects.aio import AIProjectClient
from semantic_kernel.agents.azure_ai.azure_ai_agent import AzureAIAgent
from semantic_kernel.contents.chat_history import ChatHistory
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="AgentThreadActions")
logger: logging.Logger = logging.getLogger(__name__)
@experimental
class AgentThreadActions:
"""AzureAI Agent Thread Actions."""
polling_status: ClassVar[list[str]] = ["queued", "in_progress", "cancelling"]
error_message_states: ClassVar[list[str]] = ["failed", "cancelled", "expired", "incomplete"]
# region Invocation Methods
@classmethod
async def invoke(
cls: type[_T],
*,
agent: "AzureAIAgent",
thread_id: str,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
# Run-level parameters:
model: str | None = None,
instructions_override: str | None = None,
additional_instructions: str | None = None,
additional_messages: "list[ChatMessageContent] | None" = None,
tools: list[ToolDefinition] | None = None,
temperature: float | None = None,
top_p: float | None = None,
max_prompt_tokens: int | None = None,
max_completion_tokens: int | None = None,
truncation_strategy: TruncationObject | None = None,
response_format: ResponseFormatJsonSchemaType | None = None,
parallel_tool_calls: bool | None = None,
metadata: dict[str, str] | None = None,
polling_options: RunPollingOptions | None = None,
function_choice_behavior: FunctionChoiceBehavior | None = None,
**kwargs: Any,
) -> AsyncIterable[tuple[bool, "ChatMessageContent"]]:
"""Invoke the message in the thread.
Args:
agent: The agent to invoke.
thread_id: The thread id.
arguments: The kernel arguments.
kernel: The kernel.
model: The model.
instructions_override: The instructions override.
additional_instructions: The additional instructions.
additional_messages: The additional messages to add to the thread. Only supports messages with
role = User or Assistant.
https://platform.openai.com/docs/api-reference/runs/createRun#runs-createrun-additional_messages
tools: The SDK-level tools (e.g. CodeInterpreter, FileSearch, AzureAISearch). 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.
max_prompt_tokens: The max prompt tokens.
max_completion_tokens: The max completion tokens.
truncation_strategy: The truncation strategy.
response_format: The response format.
parallel_tool_calls: The parallel tool calls.
metadata: The metadata.
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:
A tuple of the visibility flag and the invoked message.
"""
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 ""
run_options = cls._generate_options(
agent=agent,
model=model,
additional_messages=additional_messages,
max_completion_tokens=max_completion_tokens,
max_prompt_tokens=max_prompt_tokens,
temperature=temperature,
top_p=top_p,
metadata=metadata,
truncation_strategy=truncation_strategy,
response_format=response_format,
parallel_tool_calls=parallel_tool_calls,
)
# Remove keys with None values.
run_options = {k: v for k, v in run_options.items() if v is not None}
run: ThreadRun = await agent.client.agents.runs.create(
agent_id=agent.id,
thread_id=thread_id,
instructions=merged_instructions or agent.instructions,
tools=tools,
**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 = "None"
if run.last_error and run.last_error.message:
error_message = run.last_error.message
incomplete_details_reason = "None"
if run.incomplete_details and run.incomplete_details.reason:
incomplete_details_reason = 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} and incomplete details reason: {incomplete_details_reason}"
)
# Check if function calling is required
if run.status == "requires_action":
if isinstance(run.required_action, SubmitToolOutputsAction):
logger.debug(
f"Run [{run.id}] requires tool 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() if kwargs.get("chat_history") is None else kwargs["chat_history"]
_ = 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.agents.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
# Check if MCP tool approval is required
elif isinstance(run.required_action, SubmitToolApprovalAction):
logger.debug(
f"Run [{run.id}] requires MCP tool approval for agent `{agent.name}` and thread `{thread_id}`"
)
tool_calls = run.required_action.submit_tool_approval.tool_calls
if not tool_calls:
logger.warning(f"No tool calls provided for MCP approval - cancelling run [{run.id}]")
await agent.client.agents.runs.cancel(run_id=run.id, thread_id=thread_id)
continue
mcp_tool_calls = [tc for tc in tool_calls if isinstance(tc, RequiredMcpToolCall)]
if mcp_tool_calls:
logger.debug(
f"Yielding generate_mcp_call_content for agent `{agent.name}` and "
f"thread `{thread_id}`, visibility False"
)
yield False, generate_mcp_call_content(agent_name=agent.name, mcp_tool_calls=mcp_tool_calls)
# Create tool approvals for MCP calls
tool_approvals = []
for mcp_call in mcp_tool_calls:
tool_approvals.append(
ToolApproval(
tool_call_id=mcp_call.id,
# TODO(evmattso): we don't support manual tool calling yet
# so we always approve
approve=True,
)
)
await agent.client.agents.runs.submit_tool_outputs(
run_id=run.id,
thread_id=thread_id,
tool_approvals=tool_approvals, # type: ignore
)
logger.debug(f"Submitted MCP tool approvals for agent `{agent.name}` and thread `{thread_id}`")
continue
steps: list[RunStep] = []
async for steps_response in agent.client.agents.run_steps.list(thread_id=thread_id, run_id=run.id):
steps.append(steps_response)
logger.debug(f"Call for steps_response for run [{run.id}] agent `{agent.name}` and thread `{thread_id}`")
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:
match completed_step.type:
case RunStepType.TOOL_CALLS:
logger.debug(
f"Entering step type tool_calls for run [{run.id}], agent `{agent.name}` and "
f"thread `{thread_id}`"
)
tool_call_details: RunStepToolCallDetails = cast(
RunStepToolCallDetails, completed_step.step_details
)
for tool_call in tool_call_details.tool_calls:
is_visible = False
content: "ChatMessageContent | None" = None
match tool_call.type:
case AgentsNamedToolChoiceType.CODE_INTERPRETER:
logger.debug(
f"Entering tool_calls (code_interpreter) for run [{run.id}], agent "
f"`{agent.name}` and thread `{thread_id}`"
)
code_call: RunStepCodeInterpreterToolCall = cast(
RunStepCodeInterpreterToolCall, tool_call
)
content = generate_code_interpreter_content(
agent.name,
code_call.code_interpreter.input,
)
is_visible = True
case AgentsNamedToolChoiceType.FUNCTION:
logger.debug(
f"Entering tool_calls (function) for run [{run.id}], agent `{agent.name}` "
f"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, # type: ignore
)
case (
AgentsNamedToolChoiceType.BING_GROUNDING
| AgentsNamedToolChoiceType.BING_CUSTOM_SEARCH
):
logger.debug(
f"Entering tool_calls (bing_grounding/bing_custom_search) for run [{run.id}], "
f"agent `{agent.name}` and thread `{thread_id}`"
)
# Handle both Bing grounding and custom search tool calls
bing_call: RunStepBingGroundingToolCall | RunStepBingCustomSearchToolCall = cast(
RunStepBingGroundingToolCall | RunStepBingCustomSearchToolCall, tool_call
)
content = generate_bing_grounding_content(
agent_name=agent.name, bing_tool_call=bing_call
)
case AgentsNamedToolChoiceType.AZURE_AI_SEARCH:
logger.debug(
f"Entering tool_calls (azure_ai_search) for run [{run.id}], agent "
f" `{agent.name}` and thread `{thread_id}`"
)
azure_ai_search_call: RunStepAzureAISearchToolCall = cast(
RunStepAzureAISearchToolCall, tool_call
)
content = generate_azure_ai_search_content(
agent_name=agent.name, azure_ai_search_tool_call=azure_ai_search_call
)
case AgentsNamedToolChoiceType.FILE_SEARCH:
logger.debug(
f"Entering tool_calls (file_search) for run [{run.id}], agent "
f" `{agent.name}` and thread `{thread_id}`"
)
file_search_call: RunStepFileSearchToolCall = cast(
RunStepFileSearchToolCall, tool_call
)
content = generate_file_search_content(
agent_name=agent.name, file_search_tool_call=file_search_call
)
case "openapi":
logger.debug(
f"Entering tool_calls (openapi) for run [{run.id}], agent "
f" `{agent.name}` and thread `{thread_id}`"
)
openapi_tool_call: RunStepOpenAPIToolCall = cast(RunStepOpenAPIToolCall, tool_call)
content = generate_openapi_content(
agent_name=agent.name,
openapi_tool_call=openapi_tool_call,
)
case AgentsNamedToolChoiceType.MCP:
logger.debug(
f"Entering tool_calls (mcp) for run [{run.id}], agent "
f" `{agent.name}` and thread `{thread_id}`"
)
mcp_tool_call: RunStepMcpToolCall = cast(RunStepMcpToolCall, tool_call)
content = generate_mcp_content(
agent_name=agent.name,
mcp_tool_call=mcp_tool_call,
)
case AgentsNamedToolChoiceType.DEEP_RESEARCH:
logger.debug(
f"Entering tool_calls (deep_research) for run [{run.id}], agent "
f" `{agent.name}` and thread `{thread_id}`"
)
deep_research_call: RunStepDeepResearchToolCall = cast(
RunStepDeepResearchToolCall, tool_call
)
content = generate_deep_research_content(
agent_name=agent.name,
deep_research_tool_call=deep_research_call,
)
if content:
message_count += 1
logger.debug(
f"Yielding tool_message for run [{run.id}], agent `{agent.name}`, "
f"thread `{thread_id}`, message count `{message_count}`, "
f"is_visible `{is_visible}`"
)
yield is_visible, content
case RunStepType.MESSAGE_CREATION:
logger.debug(
f"Entering message_creation for run [{run.id}], agent `{agent.name}` and thread "
f"`{thread_id}`"
)
message_call_details: RunStepMessageCreationDetails = cast(
RunStepMessageCreationDetails, completed_step.step_details
)
message = await cls._retrieve_message(
agent=agent,
thread_id=thread_id,
message_id=message_call_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}`, "
f"thread `{thread_id}`, 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: "AzureAIAgent",
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,
metadata: dict[str, str] | None = None,
model: str | None = None,
max_prompt_tokens: int | None = None,
max_completion_tokens: int | None = None,
output_messages: list[ChatMessageContent] | None = None,
parallel_tool_calls: bool | None = None,
response_format: ResponseFormatJsonSchemaType | None = None,
tools: list[ToolDefinition] | None = None,
temperature: float | None = None,
top_p: float | None = None,
truncation_strategy: TruncationObject | None = None,
function_choice_behavior: FunctionChoiceBehavior | None = None,
**kwargs: Any,
) -> AsyncIterable["StreamingChatMessageContent"]:
"""Invoke the agent stream and yield ChatMessageContent continuously.
Args:
agent: The agent to invoke.
thread_id: The thread id.
additional_instructions: The additional instructions.
additional_messages: The additional messages to add to the thread. Only supports messages with
role = User or Assistant.
https://platform.openai.com/docs/api-reference/runs/createRun
arguments: The kernel arguments.
instructions_override: The instructions override.
kernel: The kernel.
metadata: The metadata.
model: The model.
max_prompt_tokens: The max prompt tokens.
max_completion_tokens: The max completion tokens.
output_messages: The output messages received from the agent. These are full content messages
formed from the streamed chunks.
parallel_tool_calls: Whether to configure parallel tool calls.
response_format: The response format.
tools: The SDK-level tools (e.g. CodeInterpreter, FileSearch, AzureAISearch). 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
arguments = agent._merge_arguments(arguments)
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 ""
run_options = cls._generate_options(
agent=agent,
model=model,
additional_messages=additional_messages,
max_completion_tokens=max_completion_tokens,
max_prompt_tokens=max_prompt_tokens,
temperature=temperature,
top_p=top_p,
metadata=metadata,
truncation_strategy=truncation_strategy,
response_format=response_format,
parallel_tool_calls=parallel_tool_calls,
)
run_options = {k: v for k, v in run_options.items() if v is not None}
stream: AsyncAgentRunStream = await agent.client.agents.runs.stream(
agent_id=agent.id,
thread_id=thread_id,
instructions=merged_instructions or agent.instructions,
tools=tools,
**run_options,
)
function_steps: dict[str, FunctionCallContent] = {}
active_messages: dict[str, RunStep] = {}
async for content in cls._process_stream_events(
stream=stream,
agent=agent,
thread_id=thread_id,
output_messages=output_messages,
kernel=kernel,
arguments=arguments,
function_steps=function_steps,
active_messages=active_messages,
function_choice_behavior=function_choice_behavior,
):
if content:
yield content
@classmethod
async def _process_stream_events(
cls: type[_T],
stream: AsyncAgentRunStream,
agent: "AzureAIAgent",
thread_id: str,
kernel: "Kernel",
arguments: KernelArguments,
function_steps: dict[str, FunctionCallContent],
active_messages: dict[str, RunStep],
output_messages: "list[ChatMessageContent] | None" = None,
function_choice_behavior: FunctionChoiceBehavior | None = None,
) -> AsyncIterable["StreamingChatMessageContent"]:
"""Process events from the main stream and delegate tool output handling as needed."""
thread_msg_id = None
while True:
# Use 'async with' only if the stream supports async context management (main agent stream).
# Tool output handlers only support async iteration, not context management.
if hasattr(stream, "__aenter__") and hasattr(stream, "__aexit__"):
async with stream as response_stream:
stream_iter = response_stream
else:
stream_iter = stream
async for event_type, event_data, _ in stream_iter:
if event_type == AgentStreamEvent.THREAD_RUN_CREATED:
run = event_data
logger.info(f"Assistant run created with ID: {run.id}")
elif event_type == AgentStreamEvent.THREAD_RUN_IN_PROGRESS:
run_step = cast(RunStep, event_data)
logger.info(f"Assistant run in progress with ID: {run_step.id}")
elif event_type == AgentStreamEvent.THREAD_MESSAGE_CREATED:
# Keep the current message id stable unless a new one arrives
if thread_msg_id != event_data.id:
thread_msg_id = event_data.id
logger.info(f"Assistant message created with ID: {thread_msg_id}")
elif event_type == AgentStreamEvent.THREAD_MESSAGE_DELTA:
yield generate_streaming_message_content(agent.name, event_data, thread_msg_id)
elif event_type == AgentStreamEvent.THREAD_RUN_STEP_COMPLETED:
step_completed = cast(RunStep, event_data)
logger.info(f"Run step completed with ID: {step_completed.id}")
if isinstance(step_completed.step_details, RunStepMessageCreationDetails):
msg_id = step_completed.step_details.message_creation.message_id
active_messages.setdefault(msg_id, step_completed)
elif event_type == AgentStreamEvent.THREAD_RUN_STEP_DELTA:
run_step_event: RunStepDeltaChunk = event_data
details = run_step_event.delta.step_details
if not details:
continue
if isinstance(details, RunStepDeltaToolCallObject) and details.tool_calls:
content_is_visible = False
for tool_call in details.tool_calls:
logger.debug(
f"Generating content for tool call type `{tool_call.type}`, agent `{agent.name}` and "
f"thread `{thread_id}` with tool call details: {details}"
)
content = None
match tool_call.type:
# Function Calling-related content is emitted as a single message
# via the `on_intermediate_message` callback.
case AgentsNamedToolChoiceType.CODE_INTERPRETER:
content = generate_streaming_code_interpreter_content(agent.name, details)
content_is_visible = True
case (
AgentsNamedToolChoiceType.BING_GROUNDING
| AgentsNamedToolChoiceType.BING_CUSTOM_SEARCH
):
content = generate_streaming_bing_grounding_content(
agent_name=agent.name, step_details=details
)
case AgentsNamedToolChoiceType.AZURE_AI_SEARCH:
content = generate_streaming_azure_ai_search_content(
agent_name=agent.name, step_details=details
)
case AgentsNamedToolChoiceType.FILE_SEARCH:
content = generate_streaming_file_search_content(
agent_name=agent.name, step_details=details
)
case "openapi":
# There's no enum for OpenAPI tool calls as part of `AgentsNamedToolChoiceType`
# so we handle it separately.
content = generate_streaming_openapi_content(
agent_name=agent.name, step_details=details
)
case AgentsNamedToolChoiceType.MCP:
content = generate_streaming_mcp_content(
agent_name=agent.name, step_details=details
)
case AgentsNamedToolChoiceType.DEEP_RESEARCH:
content = generate_streaming_deep_research_content(
agent_name=agent.name, step_details=details
)
if content:
if thread_msg_id and THREAD_MESSAGE_ID not in content.metadata:
content.metadata[THREAD_MESSAGE_ID] = thread_msg_id
if output_messages is not None:
output_messages.append(content)
if content_is_visible:
yield content
elif event_type == AgentStreamEvent.THREAD_RUN_REQUIRES_ACTION:
logger.debug(
f"Entering step type {event_type}, agent `{agent.name}` and "
f"thread `{thread_id}` with event data: {event_data}"
)
run = cast(ThreadRun, event_data)
# Check if this is a function call request
if isinstance(run.required_action, SubmitToolOutputsAction):
action_result = await cls._handle_streaming_requires_action(
agent_name=agent.name,
kernel=kernel,
run=run,
function_steps=function_steps,
arguments=arguments,
function_choice_behavior=function_choice_behavior,
)
if action_result is None:
raise RuntimeError(
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:
if thread_msg_id and THREAD_MESSAGE_ID not in content.metadata:
content.metadata[THREAD_MESSAGE_ID] = thread_msg_id
output_messages.append(content)
handler: BaseAsyncAgentEventHandler = AsyncAgentEventHandler()
await agent.client.agents.runs.submit_tool_outputs_stream(
run_id=run.id,
thread_id=thread_id,
tool_outputs=action_result.tool_outputs, # type: ignore
event_handler=handler,
)
# Pass the handler to the stream to continue processing
stream = handler # type: ignore
logger.debug(
f"Submitted tool outputs stream for agent `{agent.name}` and "
f"thread `{thread_id}` and run id `{run.id}`"
)
break
# Check if this is an MCP tool approval request
elif isinstance(run.required_action, SubmitToolApprovalAction):
tool_calls = run.required_action.submit_tool_approval.tool_calls
if not tool_calls:
logger.warning(f"No tool calls provided for MCP approval - cancelling run [{run.id}]")
await agent.client.agents.runs.cancel(run_id=run.id, thread_id=thread_id)
break
mcp_tool_calls = [tc for tc in tool_calls if isinstance(tc, RequiredMcpToolCall)]
if mcp_tool_calls:
logger.debug(
f"Processing MCP tool approvals for agent `{agent.name}` and "
f"thread `{thread_id}` and run id `{run.id}`"
)
if output_messages is not None:
content = generate_streaming_mcp_call_content(
agent_name=agent.name, mcp_tool_calls=mcp_tool_calls
)
if content:
if thread_msg_id and THREAD_MESSAGE_ID not in content.metadata:
content.metadata[THREAD_MESSAGE_ID] = thread_msg_id
output_messages.append(content)
# Create tool approvals for MCP calls
tool_approvals = []
for mcp_call in mcp_tool_calls:
tool_approvals.append(
ToolApproval(
tool_call_id=mcp_call.id,
approve=True,
# Note: headers would need to be provided by the MCP tool configuration
# This is a simplified implementation
headers={},
)
)
handler: BaseAsyncAgentEventHandler = AsyncAgentEventHandler() # type: ignore
await agent.client.agents.runs.submit_tool_outputs_stream(
run_id=run.id,
thread_id=thread_id,
tool_approvals=tool_approvals, # type: ignore
event_handler=handler,
)
# Pass the handler to the stream to continue processing
stream = handler # type: ignore
logger.debug(
f"Submitted MCP tool approvals stream for agent `{agent.name}` and "
f"thread `{thread_id}` and run id `{run.id}`"
)
break
elif event_type == AgentStreamEvent.THREAD_RUN_COMPLETED:
logger.debug(
f"Entering step type {event_type}, agent `{agent.name}` and "
f"thread `{thread_id}` and run id `{run.id}`"
)
run = cast(ThreadRun, event_data)
logger.info(f"Run completed with ID: {run.id}")
if active_messages:
for msg_id, step in active_messages.items():
message = await cls._retrieve_message(agent=agent, thread_id=thread_id, message_id=msg_id)
if message and hasattr(message, "content"):
final_content = generate_message_content(agent.name, message, step)
if output_messages is not None:
output_messages.append(final_content)
return
elif event_type == AgentStreamEvent.THREAD_RUN_FAILED:
run_failed = cast(ThreadRun, event_data)
error_message = "None"
if run_failed.last_error and run_failed.last_error.message:
error_message = run_failed.last_error.message
incomplete_details_reason = "None"
if run_failed.incomplete_details and run_failed.incomplete_details.reason:
incomplete_details_reason = run_failed.incomplete_details.reason
raise RuntimeError(
f"Run failed with status: `{run_failed.status}` for agent `{agent.name}` "
f"thread `{thread_id}` with error: {error_message} and incomplete details reason: "
f"{incomplete_details_reason}"
)
else:
break
return
# endregion
# region Messaging Handling Methods
@classmethod
async def create_thread(
cls: type[_T],
client: "AIProjectClient",
**kwargs: Any,
) -> str:
"""Create a thread.
Args:
client: The client to use to create the thread.
kwargs: Additional keyword arguments.
Returns:
The ID of the created thread.
"""
thread = await client.agents.threads.create(**kwargs)
return thread.id
@classmethod
async def create_message(
cls: type[_T],
client: "AIProjectClient",
thread_id: str,
message: "str | ChatMessageContent",
**kwargs: Any,
) -> "ThreadMessage | 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.
kwargs: Additional keyword arguments.
Returns:
The created message.
"""
if isinstance(message, str):
message = ChatMessageContent(role=AuthorRole.USER, content=message)
if any(isinstance(item, FunctionCallContent) for item in message.items):
return None
if not message.content.strip():
return None
return await client.agents.messages.create(
thread_id=thread_id,
role=MessageRole.USER if message.role == AuthorRole.USER else MessageRole.AGENT,
content=message.content,
attachments=AzureAIAgentUtils.get_attachments(message),
metadata=AzureAIAgentUtils.get_metadata(message),
**kwargs,
)
@classmethod
async def get_messages(
cls: type[_T],
client: "AIProjectClient",
thread_id: str,
sort_order: Literal["asc", "desc"] = "desc",
) -> AsyncIterable["ChatMessageContent"]:
"""Get messages from a 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 order to sort the messages in.
Yields:
An AsyncIterable of ChatMessageContent that includes the thread messages.
Raises:
AgentThreadOperationException: If the messages cannot be retrieved.
"""
try:
async for message in client.agents.messages.list(
thread_id=thread_id,
run_id=None,
limit=None,
order=sort_order,
before=None,
):
agent_id = (message.agent_id or message.metadata.get("agent_id") or "").strip() or "agent"
yield generate_message_content(agent_id, message)
except Exception as e:
logger.error(f"Failed to retrieve messages for thread {thread_id}: {e}")
raise AgentThreadOperationException(f"Failed to retrieve messages for thread `{thread_id}`.") from e
# endregion
# region Internal Methods
@classmethod
def _merge_options(
cls: type[_T],
*,
agent: "AzureAIAgent",
model: str | None = None,
response_format: ResponseFormatJsonSchemaType | 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 agent.definition.response_format,
"temperature": temperature if temperature is not None else None,
"top_p": top_p if top_p is not None else None,
"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)
truncation_strategy = merged.get("truncation_strategy", 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(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": truncation_strategy,
"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], messages: "list[ChatMessageContent] | None"
) -> list[ThreadMessage] | None:
"""Translate additional messages to the required format."""
if not messages:
return None
return AzureAIAgentUtils.get_thread_messages(messages)
@classmethod
def _prepare_tool_definition(cls: type[_T], tool: dict | ToolDefinition) -> dict | ToolDefinition:
"""Prepare the tool definition."""
if tool.get("type") == "openapi" and "openapi" in tool:
openapi_data = dict(tool["openapi"])
openapi_data.pop("functions", None)
tool = dict(tool)
tool["openapi"] = openapi_data
return tool
@staticmethod
def _deduplicate_tools(existing_tools: list[dict], new_tools: list[dict]) -> list[dict]:
existing_names = {
tool["function"]["name"] for tool in existing_tools if "function" in tool and "name" in tool["function"]
}
return [tool for tool in new_tools if tool.get("function", {}).get("name") not in existing_names]
@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: "AzureAIAgent",
kernel: "Kernel",
tools_override: list[ToolDefinition] | None = None,
function_choice_behavior: FunctionChoiceBehavior | None = None,
) -> list[dict[str, Any] | ToolDefinition]:
"""Get the tools for the agent.
Args:
agent: The agent instance.
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.
"""
tools: list[Any] = list(tools_override) if tools_override is not None else list(agent.definition.tools)
# Always validate against the full kernel function list to catch truly
# unregistered functions, regardless of FCB filtering.
all_funcs = kernel.get_full_list_of_function_metadata()
cls._validate_function_tools_registered(tools, all_funcs)
# Determine which kernel functions to advertise based on function_choice_behavior
if function_choice_behavior is not None and not function_choice_behavior.enable_kernel_functions:
funcs: list[KernelFunctionMetadata] = []
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 = all_funcs
dict_defs = [kernel_function_metadata_to_function_call_format(f) for f in funcs]
deduped_defs = cls._deduplicate_tools(tools, dict_defs)
tools.extend(deduped_defs)
return [cls._prepare_tool_definition(tool) for tool in tools]
@staticmethod
def _validate_function_tools_registered(
tools: list[Any],
funcs: list[Any],
) -> None:
"""Validate that all function tools are registered with the kernel."""
function_tool_names = set()
for tool in tools:
if isinstance(tool, FunctionToolDefinition):
agent_tool_func_name = getattr(tool.function, "name", None)
if agent_tool_func_name:
function_tool_names.add(agent_tool_func_name)
kernel_function_names = set()
for f in funcs:
kernel_func_name = (
f.fully_qualified_name
if isinstance(f, KernelFunctionMetadata)
else getattr(f, "full_qualified_name", None)
)
if kernel_func_name:
kernel_function_names.add(kernel_func_name)
missing_functions = function_tool_names - kernel_function_names
if missing_functions:
raise AgentInvokeException(
f"The following function tool(s) are defined on the agent but missing from the kernel: "
f"{sorted(missing_functions)}. "
f"Please ensure all required tools are registered with the kernel."
)
@classmethod
async def _poll_run_status(
cls: type[_T], agent: "AzureAIAgent", run: ThreadRun, thread_id: str, polling_options: RunPollingOptions
) -> ThreadRun:
"""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=agent, run=run, thread_id=thread_id, polling_options=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}` "
f"after waiting {timeout_duration}."
)
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: "AzureAIAgent", run: ThreadRun, thread_id: str, polling_options: RunPollingOptions
) -> ThreadRun:
"""Continuously poll the run status until it is no longer pending."""
count = 0
while True:
await asyncio.sleep(polling_options.get_polling_interval(count).total_seconds())
count += 1
try:
run = await agent.client.agents.runs.get(run_id=run.id, thread_id=thread_id)
except Exception as e:
logger.warning(f"Failed to retrieve run for run id: `{run.id}` and thread id: `{thread_id}`: {e}")
if run.status not in cls.polling_status:
break
return run
@classmethod
async def _retrieve_message(
cls: type[_T], agent: "AzureAIAgent", thread_id: str, message_id: str
) -> ThreadMessage | None:
"""Retrieve a message from a thread."""
message: ThreadMessage | None = None
count = 0
max_retries = 3
while count < max_retries:
try:
message = await agent.client.agents.messages.get(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 _handle_streaming_requires_action(
cls: type[_T],
agent_name: str,
kernel: "Kernel",
run: ThreadRun,
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