Files
semantic-kernel/python/semantic_kernel/agents/chat_completion/chat_completion_agent.py
T
Evan Mattson 1a187b03bc Python: Introduce the Copilot Studio Agent (#11851)
### Motivation and Context

Semantic Kernel Python currently has a demo showing how to interact with
a Copilot Studio Agent; however, that uses the `DirectLine API`. This PR
introduces a Semantic Kernel `CoilotStudioAgent` that leverages the
team's new `microsoft.agents.copilotstudio.client`. One must first have
a Copilot Studio Agent created via the portal; and once ready, they can
reference it with the proper App Registration and Agent metadata.
Interacting with the Agent can leverage Microsoft Copilot Studio's
knowledge sources and internal tools.

<!-- Thank you for your contribution to the semantic-kernel repo!
Please help reviewers and future users, providing the following
information:
  1. Why is this change required?
  2. What problem does it solve?
  3. What scenario does it contribute to?
  4. If it fixes an open issue, please link to the issue here.
-->

### Description

Add support for the `CopilotStudioAgent`. 
- Provide getting started samples
- Introduces a detailed README on what is required for setup and
interacting with the agent.
- The current version of the `CopilotStudioAgent` does not support
calling another Copilot Studio Agent via a skill.
- Authentication for the agent is limited to interactive right now. Once
service-to-service auth is support via the client, we will add this into
SK.
- Closes #11607

<!-- Describe your changes, the overall approach, the underlying design.
These notes will help understanding how your code works. Thanks! -->

### Contribution Checklist

<!-- Before submitting this PR, please make sure: -->

- [X] The code builds clean without any errors or warnings
- [X] The PR follows the [SK Contribution
Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md)
and the [pre-submission formatting
script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts)
raises no violations
- [X] All unit tests pass, and I have added new tests where possible
- [X] I didn't break anyone 😄
2025-05-06 10:29:29 +00:00

558 lines
22 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import logging
import sys
import uuid
from collections.abc import AsyncGenerator, AsyncIterable, Awaitable, Callable
from typing import TYPE_CHECKING, Any, ClassVar
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
from pydantic import Field, model_validator
from semantic_kernel.agents import Agent
from semantic_kernel.agents.agent import AgentResponseItem, AgentThread
from semantic_kernel.agents.channels.agent_channel import AgentChannel
from semantic_kernel.agents.channels.chat_history_channel import ChatHistoryChannel
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
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.function_result_content import FunctionResultContent
from semantic_kernel.contents.history_reducer.chat_history_reducer import ChatHistoryReducer
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.exceptions import KernelServiceNotFoundError
from semantic_kernel.exceptions.agent_exceptions import (
AgentInitializationException,
AgentInvokeException,
AgentThreadOperationException,
)
from semantic_kernel.functions.kernel_arguments import KernelArguments
from semantic_kernel.functions.kernel_function import TEMPLATE_FORMAT_MAP
from semantic_kernel.functions.kernel_plugin import KernelPlugin
from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig
from semantic_kernel.utils.telemetry.agent_diagnostics.decorators import (
trace_agent_get_response,
trace_agent_invocation,
)
if TYPE_CHECKING:
from semantic_kernel.kernel import Kernel
logger: logging.Logger = logging.getLogger(__name__)
class ChatHistoryAgentThread(AgentThread):
"""Chat History Agent Thread class."""
def __init__(self, chat_history: ChatHistory | None = None, thread_id: str | None = None) -> None:
"""Initialize the ChatCompletionAgent Thread.
Args:
chat_history: The chat history for the thread. If None, a new ChatHistory instance will be created.
thread_id: The ID of the thread. If None, a new thread will be created.
"""
super().__init__()
self._chat_history = chat_history or ChatHistory()
self._id: str = thread_id or f"thread_{uuid.uuid4().hex}"
self._is_deleted = False
def __len__(self) -> int:
"""Returns the length of the chat history."""
return len(self._chat_history)
@override
async def _create(self) -> str:
"""Starts the thread and returns its ID."""
return self._id
@override
async def _delete(self) -> None:
"""Ends the current thread."""
self._chat_history.clear()
@override
async def _on_new_message(self, new_message: str | ChatMessageContent) -> None:
"""Called when a new message has been contributed to the chat."""
if isinstance(new_message, str):
new_message = ChatMessageContent(role=AuthorRole.USER, content=new_message)
if (
not new_message.metadata
or "thread_id" not in new_message.metadata
or new_message.metadata["thread_id"] != self._id
):
self._chat_history.add_message(new_message)
async def get_messages(self) -> AsyncIterable[ChatMessageContent]:
"""Retrieve the current chat history.
Returns:
An async iterable of ChatMessageContent.
"""
if self._is_deleted:
raise AgentThreadOperationException("Cannot retrieve chat history, since the thread has been deleted.")
if self._id is None:
await self.create()
for message in self._chat_history.messages:
yield message
async def reduce(self) -> ChatHistory | None:
"""Reduce the chat history to a smaller size."""
if self._id is None:
raise AgentThreadOperationException("Cannot reduce chat history, since the thread is not currently active.")
if not isinstance(self._chat_history, ChatHistoryReducer):
return None
return await self._chat_history.reduce()
class ChatCompletionAgent(Agent):
"""A Chat Completion Agent based on ChatCompletionClientBase."""
function_choice_behavior: FunctionChoiceBehavior | None = Field(
default_factory=lambda: FunctionChoiceBehavior.Auto()
)
channel_type: ClassVar[type[AgentChannel] | None] = ChatHistoryChannel
service: ChatCompletionClientBase | None = Field(default=None, exclude=True)
def __init__(
self,
*,
arguments: KernelArguments | None = None,
description: str | None = None,
function_choice_behavior: FunctionChoiceBehavior | None = None,
id: str | None = None,
instructions: str | None = None,
kernel: "Kernel | None" = None,
name: str | None = None,
plugins: list[KernelPlugin | object] | dict[str, KernelPlugin | object] | None = None,
prompt_template_config: PromptTemplateConfig | None = None,
service: ChatCompletionClientBase | None = None,
) -> None:
"""Initialize a new instance of ChatCompletionAgent.
Args:
arguments: The kernel arguments for the agent. Invoke method arguments take precedence over
the arguments provided here.
description: The description of the agent.
function_choice_behavior: The function choice behavior to determine how and which plugins are
advertised to the model.
kernel: The kernel instance. If both a kernel and a service are provided, the service will take precedence
if they share the same service_id or ai_model_id. Otherwise if separate, the first AI service
registered on the kernel will be used.
id: The unique identifier for the agent. If not provided,
a unique GUID will be generated.
instructions: The instructions for the agent.
name: The name of the agent.
plugins: The plugins for the agent. If plugins are included along with a kernel, any plugins
that already exist in the kernel will be overwritten.
prompt_template_config: The prompt template configuration for the agent.
service: The chat completion service instance. If a kernel is provided with the same service_id or
`ai_model_id`, the service will take precedence.
"""
args: dict[str, Any] = {
"description": description,
}
if name is not None:
args["name"] = name
if id is not None:
args["id"] = id
if kernel is not None:
args["kernel"] = kernel
if arguments is not None:
args["arguments"] = arguments
if instructions and prompt_template_config and instructions != prompt_template_config.template:
logger.info(
f"Both `instructions` ({instructions}) and `prompt_template_config` "
f"({prompt_template_config.template}) were provided. Using template in `prompt_template_config` "
"and ignoring `instructions`."
)
if plugins is not None:
args["plugins"] = plugins
if function_choice_behavior is not None:
args["function_choice_behavior"] = function_choice_behavior
if service is not None:
args["service"] = service
if instructions is not None:
args["instructions"] = instructions
if prompt_template_config is not None:
args["prompt_template"] = TEMPLATE_FORMAT_MAP[prompt_template_config.template_format](
prompt_template_config=prompt_template_config
)
if prompt_template_config.template is not None:
# Use the template from the prompt_template_config if it is provided
args["instructions"] = prompt_template_config.template
super().__init__(**args)
@model_validator(mode="after")
def configure_service(self) -> "ChatCompletionAgent":
"""Configure the service used by the ChatCompletionAgent."""
if self.service is None:
return self
if not isinstance(self.service, ChatCompletionClientBase):
raise AgentInitializationException(
f"Service provided for ChatCompletionAgent is not an instance of ChatCompletionClientBase. "
f"Service: {type(self.service)}"
)
self.kernel.add_service(self.service, overwrite=True)
return self
async def create_channel(
self, chat_history: ChatHistory | None = None, thread_id: str | None = None
) -> AgentChannel:
"""Create a ChatHistoryChannel.
Args:
chat_history: The chat history for the channel. If None, a new ChatHistory instance will be created.
thread_id: The ID of the thread. If None, a new thread will be created.
Returns:
An instance of AgentChannel.
"""
from semantic_kernel.agents.chat_completion.chat_completion_agent import ChatHistoryAgentThread
ChatHistoryChannel.model_rebuild()
thread = ChatHistoryAgentThread(chat_history=chat_history, thread_id=thread_id)
if thread.id is None:
await thread.create()
messages = [message async for message in thread.get_messages()]
return ChatHistoryChannel(messages=messages, thread=thread)
@trace_agent_get_response
@override
async def get_response(
self,
*,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
thread: AgentThread | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs: Any,
) -> AgentResponseItem[ChatMessageContent]:
"""Get a response from the agent.
Args:
messages: The input chat message content either as a string, ChatMessageContent or
a list of strings or ChatMessageContent.
thread: The thread to use for agent invocation.
arguments: The kernel arguments.
kernel: The kernel instance.
kwargs: The keyword arguments.
Returns:
An AgentResponseItem of type ChatMessageContent.
"""
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: ChatHistoryAgentThread(),
expected_type=ChatHistoryAgentThread,
)
assert thread.id is not None # nosec
chat_history = ChatHistory()
async for message in thread.get_messages():
chat_history.add_message(message)
responses: list[ChatMessageContent] = []
async for response in self._inner_invoke(
thread,
chat_history,
None,
arguments,
kernel,
**kwargs,
):
responses.append(response)
if not responses:
raise AgentInvokeException("No response from agent.")
return AgentResponseItem(message=responses[-1], thread=thread)
@trace_agent_invocation
@override
async def invoke(
self,
*,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
thread: AgentThread | None = None,
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseItem[ChatMessageContent]]:
"""Invoke the chat history handler.
Args:
messages: The input chat message content either as a string, ChatMessageContent or
a list of strings or ChatMessageContent.
thread: The thread to use for agent invocation.
on_intermediate_message: A callback function to handle intermediate steps of the agent's execution.
arguments: The kernel arguments.
kernel: The kernel instance.
kwargs: The keyword arguments.
Returns:
An async iterable of AgentResponseItem of type ChatMessageContent.
"""
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: ChatHistoryAgentThread(),
expected_type=ChatHistoryAgentThread,
)
assert thread.id is not None # nosec
chat_history = ChatHistory()
async for message in thread.get_messages():
chat_history.add_message(message)
async for response in self._inner_invoke(
thread,
chat_history,
on_intermediate_message,
arguments,
kernel,
**kwargs,
):
yield AgentResponseItem(message=response, thread=thread)
@trace_agent_invocation
@override
async def invoke_stream(
self,
*,
messages: str | ChatMessageContent | list[str | ChatMessageContent] | None = None,
thread: AgentThread | None = None,
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseItem[StreamingChatMessageContent]]:
"""Invoke the chat history handler in streaming mode.
Args:
messages: The chat message content either as a string, ChatMessageContent or
a list of str or ChatMessageContent.
thread: The thread to use for agent invocation.
on_intermediate_message: A callback function to handle intermediate steps of the
agent's execution as fully formed messages.
arguments: The kernel arguments.
kernel: The kernel instance.
kwargs: The keyword arguments.
Returns:
An async generator of AgentResponseItem of type StreamingChatMessageContent.
"""
thread = await self._ensure_thread_exists_with_messages(
messages=messages,
thread=thread,
construct_thread=lambda: ChatHistoryAgentThread(),
expected_type=ChatHistoryAgentThread,
)
assert thread.id is not None # nosec
chat_history = ChatHistory()
async for message in thread.get_messages():
chat_history.add_message(message)
if arguments is None:
arguments = KernelArguments(**kwargs)
else:
arguments.update(kwargs)
kernel = kernel or self.kernel
arguments = self._merge_arguments(arguments)
chat_completion_service, settings = await self._get_chat_completion_service_and_settings(
kernel=kernel, arguments=arguments
)
# If the user hasn't provided a function choice behavior, use the agent's default.
if settings.function_choice_behavior is None:
settings.function_choice_behavior = self.function_choice_behavior
agent_chat_history = await self._prepare_agent_chat_history(
history=chat_history,
kernel=kernel,
arguments=arguments,
)
message_count_before_completion = len(agent_chat_history)
logger.debug(f"[{type(self).__name__}] Invoking {type(chat_completion_service).__name__}.")
responses: AsyncGenerator[list[StreamingChatMessageContent], Any] = (
chat_completion_service.get_streaming_chat_message_contents(
chat_history=agent_chat_history,
settings=settings,
kernel=kernel,
arguments=arguments,
)
)
logger.debug(
f"[{type(self).__name__}] Invoked {type(chat_completion_service).__name__} "
f"with message count: {message_count_before_completion}."
)
role = None
response_builder: list[str] = []
async for response_list in responses:
for response in response_list:
role = response.role
response.name = self.name
response_builder.append(response.content)
if (
role == AuthorRole.ASSISTANT
and response.items
and not any(
isinstance(item, (FunctionCallContent, FunctionResultContent)) for item in response.items
)
):
yield AgentResponseItem(message=response, thread=thread)
await self._capture_mutated_messages(
agent_chat_history,
message_count_before_completion,
thread,
on_intermediate_message,
)
if role != AuthorRole.TOOL:
# Tool messages will be automatically added to the chat history by the auto function invocation loop
# if it's the response (i.e. terminated by a filter), thus we need to avoid notifying the thread about
# them multiple times.
await thread.on_new_message(
ChatMessageContent(
role=role if role else AuthorRole.ASSISTANT, content="".join(response_builder), name=self.name
)
)
async def _inner_invoke(
self,
thread: ChatHistoryAgentThread,
history: ChatHistory,
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
arguments: KernelArguments | None = None,
kernel: "Kernel | None" = None,
**kwargs: Any,
) -> AsyncIterable[ChatMessageContent]:
"""Helper method to invoke the agent with a chat history in non-streaming mode."""
if arguments is None:
arguments = KernelArguments(**kwargs)
else:
arguments.update(kwargs)
kernel = kernel or self.kernel
arguments = self._merge_arguments(arguments)
chat_completion_service, settings = await self._get_chat_completion_service_and_settings(
kernel=kernel, arguments=arguments
)
# If the user hasn't provided a function choice behavior, use the agent's default.
if settings.function_choice_behavior is None:
settings.function_choice_behavior = self.function_choice_behavior
agent_chat_history = await self._prepare_agent_chat_history(
history=history,
kernel=kernel,
arguments=arguments,
)
message_count_before_completion = len(agent_chat_history)
logger.debug(f"[{type(self).__name__}] Invoking {type(chat_completion_service).__name__}.")
responses = await chat_completion_service.get_chat_message_contents(
chat_history=agent_chat_history,
settings=settings,
kernel=kernel,
arguments=arguments,
)
logger.debug(
f"[{type(self).__name__}] Invoked {type(chat_completion_service).__name__} "
f"with message count: {message_count_before_completion}."
)
await self._capture_mutated_messages(
agent_chat_history,
message_count_before_completion,
thread,
on_intermediate_message,
)
for response in responses:
response.name = self.name
if response.role != AuthorRole.TOOL:
# Tool messages will be automatically added to the chat history by the auto function invocation loop
# if it's the response (i.e. terminated by a filter),, thus we need to avoid notifying the thread about
# them multiple times.
await thread.on_new_message(response)
yield response
async def _prepare_agent_chat_history(
self, history: ChatHistory, kernel: "Kernel", arguments: KernelArguments
) -> ChatHistory:
"""Prepare the agent chat history from the input history by adding the formatted instructions."""
formatted_instructions = await self.format_instructions(kernel, arguments)
messages = []
if formatted_instructions:
messages.append(ChatMessageContent(role=AuthorRole.SYSTEM, content=formatted_instructions, name=self.name))
if history.messages:
messages.extend(history.messages)
return ChatHistory(messages=messages)
async def _get_chat_completion_service_and_settings(
self, kernel: "Kernel", arguments: KernelArguments
) -> tuple[ChatCompletionClientBase, PromptExecutionSettings]:
"""Get the chat completion service and settings."""
chat_completion_service, settings = kernel.select_ai_service(arguments=arguments, type=ChatCompletionClientBase)
if not chat_completion_service:
raise KernelServiceNotFoundError(
"Chat completion service not found. Check your service or kernel configuration."
)
assert isinstance(chat_completion_service, ChatCompletionClientBase) # nosec
assert settings is not None # nosec
return chat_completion_service, settings
async def _capture_mutated_messages(
self,
agent_chat_history: ChatHistory,
start: int,
thread: ChatHistoryAgentThread,
on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None,
) -> None:
"""Capture mutated messages related function calling/tools."""
for message_index in range(start, len(agent_chat_history)):
message = agent_chat_history[message_index] # type: ignore
message.name = self.name
await thread.on_new_message(message)
if on_intermediate_message:
await on_intermediate_message(message)