# Copyright (c) Microsoft. All rights reserved. import logging import sys from collections.abc import AsyncIterable, Awaitable, Callable, Sequence from os import environ, path from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, Literal from microsoft_agents.activity import ActivityTypes from microsoft_agents.copilotstudio.client import AgentType, CopilotClient, PowerPlatformCloud from msal import ConfidentialClientApplication, PublicClientApplication from msal_extensions import FilePersistence, PersistedTokenCache, build_encrypted_persistence from pydantic import ValidationError 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.copilot_studio.copilot_studio_agent_settings import ( CopilotStudioAgentAuthMode, CopilotStudioAgentSettings, ) from semantic_kernel.contents.chat_message_content import ChatMessageContent from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent from semantic_kernel.contents.utils.author_role import AuthorRole from semantic_kernel.exceptions.agent_exceptions import ( AgentInitializationException, AgentThreadInitializationException, AgentThreadOperationException, ) from semantic_kernel.functions import KernelArguments from semantic_kernel.functions.kernel_function import TEMPLATE_FORMAT_MAP from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig from semantic_kernel.utils.feature_stage_decorator import experimental from semantic_kernel.utils.naming import generate_random_ascii_name from semantic_kernel.utils.telemetry.agent_diagnostics.decorators import ( trace_agent_get_response, trace_agent_invocation, trace_agent_streaming_invocation, ) if sys.version_info >= (3, 12): from typing import override # pragma: no cover else: # pragma: no cover from typing_extensions import override if TYPE_CHECKING: # pragma: no cover from semantic_kernel.kernel import Kernel logger: logging.Logger = logging.getLogger(__name__) # region Token Factory @experimental def _log_auth_failure(result: dict[str, Any]) -> None: category = result.get("error", "unknown_error") corr_id = result.get("correlation_id", "n/a")[:8] # Only log the first 8 characters logger.error(f"Copilot auth failure, category={category}, corr_id={corr_id}") @experimental class _CopilotStudioAgentTokenFactory: """A CopilotStudioAgentTokenFactory to handle authentication for the Copilot Studio agent.""" def __init__( self, *, settings: CopilotStudioAgentSettings, cache_path: str, mode: CopilotStudioAgentAuthMode, client_secret: str | None = None, client_certificate: Path | None = None, user_assertion: str | None = None, scopes: Sequence[str] | None = None, ) -> None: self.settings = settings self.cache = self._get_msal_token_cache(cache_path) self.mode = mode self.client_secret = client_secret self.client_cert_path = client_certificate self.user_assertion = user_assertion self.scopes = scopes or ["https://api.powerplatform.com/.default"] @staticmethod def _get_msal_token_cache(cache_path: str, fallback_to_plaintext=True) -> PersistedTokenCache: """Get the MSAL token cache.""" persistence = None # Note: This stores both encrypted persistence and plaintext persistence # into same location, therefore their data would likely override with each other. try: persistence = build_encrypted_persistence(cache_path) except Exception: # pylint: disable=bare-except # On Linux, encryption exception will be raised during initialization. # On Windows and macOS, they won't be detected here, # but will be raised during their load() or save(). if not fallback_to_plaintext: raise logging.warning("Encryption unavailable. Opting in to plain text.") persistence = FilePersistence(cache_path) return PersistedTokenCache(persistence) def acquire(self) -> str: """Return a valid bearer token or raise AgentInitializationException.""" if self.mode is CopilotStudioAgentAuthMode.SERVICE: # SERVICE auth wiring is present but not yet supported end-to-end. logger.warning("SERVICE authentication mode is not yet supported; falling back to error.") raise AgentInitializationException( "Copilot Studio SERVICE authentication is not available yet. Please use INTERACTIVE mode instead." ) match self.mode: case CopilotStudioAgentAuthMode.SERVICE: return self._acquire_service_token() # unreachable until the guard is removed case _: return self._acquire_interactive_token() def _new_confidential_client(self, **extra_kwargs) -> ConfidentialClientApplication: return ConfidentialClientApplication( client_id=self.settings.app_client_id, authority=f"https://login.microsoftonline.com/{self.settings.tenant_id}", token_cache=self.cache, **extra_kwargs, ) def _acquire_service_token(self) -> str: if not self.client_secret and not self.client_cert_path: raise AgentInitializationException( "client_secret *or* client_certificate is required for service-to-service auth." ) kwargs: dict[str, Any] = {} if self.client_secret: kwargs["client_credential"] = self.client_secret else: # certificate if not self.client_cert_path: raise AgentInitializationException( "If no client_secret is provided, a client_certificate is required for service-to-service auth." ) kwargs["client_credential"] = { "private_key": Path(self.client_cert_path).read_text(), "thumbprint": self._cert_thumbprint(self.client_cert_path), } app = self._new_confidential_client(**kwargs) # proactive caching result = app.acquire_token_silent(self.scopes, account=None) or app.acquire_token_for_client(scopes=self.scopes) return self._unwrap(result) # interactive def _acquire_interactive_token(self) -> str: app = PublicClientApplication( self.settings.app_client_id, authority=f"https://login.microsoftonline.com/{self.settings.tenant_id}", token_cache=self.cache, ) accounts = app.get_accounts() result = ( app.acquire_token_silent(self.scopes, account=accounts[0]) if accounts else app.acquire_token_interactive(self.scopes) ) return self._unwrap(result) @staticmethod def _unwrap(result: dict[str, Any]) -> str: if "access_token" in result: return result["access_token"] _log_auth_failure(result) raise AgentInitializationException("Authentication failed; see logs for category and correlation code.") @staticmethod def _cert_thumbprint(cert_path: Path) -> str: import hashlib import ssl pem_bytes = Path(cert_path).read_bytes() der_bytes = ssl.PEM_cert_to_DER_cert(pem_bytes.decode()) return hashlib.sha1(der_bytes, usedforsecurity=False).hexdigest().upper() # endregion # region CopilotStudioAgentThread @experimental class CopilotStudioAgentThread(AgentThread): """The Copilot Studio Agent Thread.""" def __init__( self, client: CopilotClient, conversation_id: str | None = None, ) -> None: """Initializes a new instance of the CopilotStudioAgentThread class. Args: client: The Copilot Client. conversation_id: The conversation ID. This is the Copilot Studio conversation ID. """ super().__init__() if client is None: raise AgentThreadInitializationException("CopilotClient cannot be None") self._client = client self._conversation_id = conversation_id # Copilot Studio conversation ID @property def conversation_id(self) -> str | None: """Get the conversation ID.""" return self._conversation_id @conversation_id.setter def conversation_id(self, value: str | None) -> None: """Set the conversation ID.""" self._conversation_id = value @override @property def id(self) -> str | None: """Get the thread ID.""" return self.conversation_id @override async def _create(self) -> str: # Creation is deferred to CopilotStudioAgent._ensure_conversation. if self._is_deleted: raise AgentThreadOperationException("Cannot create a thread that has been deleted.") return "" @override async def _delete(self) -> None: if self._is_deleted: return if self.conversation_id is None: raise AgentThreadOperationException("Cannot delete the thread, since it has not been created.") self._conversation_id = None self._is_deleted = True @override async def _on_new_message(self, new_message: ChatMessageContent) -> None: raise NotImplementedError( "This method is not implemented for CopilotStudioAgent. " "Messages and responses are automatically handled by the Copilot Agent." ) @experimental class CopilotStudioAgent(Agent): """Semantic Kernel abstraction over a Copilot Studio Agent.""" client: CopilotClient channel_type: ClassVar[type[AgentChannel] | None] = None def __init__( self, *, client: CopilotClient | None = None, arguments: KernelArguments | None = None, description: str | None = None, id: str | None = None, instructions: str | None = None, kernel: "Kernel | None" = None, name: str | None = None, prompt_template_config: PromptTemplateConfig | None = None, ) -> None: """Initializes a new instance of the CopilotStudioAgent class. Args: client: The Copilot Client arguments: The Kernel Arguments to use at the agent-level. description: The description of the agent. id: The unique identifier for the agent. If not provided, a unique GUID will be generated. instructions: The instructions for the agent. kernel: The kernel instance. name: The name of the agent. prompt_template_config: The prompt template configuration for the agent. """ if client is None: client = self.create_client() args: dict[str, Any] = { "client": client, "name": name or f"copilot_agent_{generate_random_ascii_name(6)}", "description": description, } if id is not None: args["id"] = id if instructions is not None: args["instructions"] = instructions 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( "Both `instructions` and `prompt_template_config` supplied " "using the template inside `prompt_template_config`." ) if prompt_template_config: args["prompt_template"] = TEMPLATE_FORMAT_MAP[prompt_template_config.template_format]( prompt_template_config=prompt_template_config ) # Overrides raw instructions if template contains the final string if prompt_template_config.template: args["instructions"] = prompt_template_config.template super().__init__(**args) def model_post_init(self, __ctx: Any) -> None: """Post-initialization hook for the model.""" super().model_post_init(__ctx) if self.kernel.plugins: logger.warning("Plugins are not supported by CopilotStudioAgent; any kernel plugins will be ignored.") @staticmethod def create_client( *, auth_mode: CopilotStudioAgentAuthMode | Literal["interactive", "service"] | None = None, agent_identifier: str | None = None, app_client_id: str | None = None, client_secret: str | None = None, client_certificate: str | None = None, cloud: PowerPlatformCloud | None = None, copilot_agent_type: AgentType | None = None, custom_power_platform_cloud: str | None = None, env_file_encoding: str | None = None, env_file_path: str | None = None, environment_id: str | None = None, tenant_id: str | None = None, user_assertion: str | None = None, ) -> CopilotClient: """Create the Copilot Studio Agent Client. Args: auth_mode: The authentication mode. This can be either `interactive` or `service`. agent_identifier: The agent identifier. This is the `Schema Name` of the agent from the Copilot Studio Advanced Metadata settings. app_client_id: The app client ID. This is the app ID of the app registration configured in Entra. client_secret: The client secret. This is the secret of the app registration. client_certificate: The client certificate. This is the certificate of the app registration. cloud: The cloud environment. copilot_agent_type: The type of Copilot agent. custom_power_platform_cloud: The custom Power Platform cloud. env_file_path: The path to the environment file. env_file_encoding: The encoding of the environment file. environment_id: The environment ID. This is from the Copilot Studio Advanced Metadata settings. tenant_id: The tenant ID. This is the tenant ID related to the app registration. user_assertion: The user assertion. This is the token used for on-behalf-of authentication. Returns: CopilotClient: The Copilot client. """ if auth_mode is not None and isinstance(auth_mode, str): auth_mode = CopilotStudioAgentAuthMode(auth_mode) try: connection_settings = CopilotStudioAgentSettings( app_client_id=app_client_id, tenant_id=tenant_id, environment_id=environment_id, agent_identifier=agent_identifier, cloud=cloud, type=copilot_agent_type, custom_power_platform_cloud=custom_power_platform_cloud, env_file_path=env_file_path, env_file_encoding=env_file_encoding, client_secret=client_secret, client_certificate=client_certificate, user_assertion=user_assertion, auth_mode=auth_mode, ) except ValidationError as exc: raise AgentInitializationException(f"Failed to create Copilot Studio Agent settings: {exc}") from exc missing_params = [name for name in ("app_client_id", "tenant_id") if not getattr(connection_settings, name)] if missing_params: raise AgentInitializationException(f"Missing required configuration field(s): {', '.join(missing_params)}") cache_file = environ.get("TOKEN_CACHE_PATH_INTERACTIVE") or path.join( path.dirname(__file__), "bin", "token_cache_interactive.bin" ) token = _CopilotStudioAgentTokenFactory( settings=connection_settings, cache_path=cache_file, mode=connection_settings.auth_mode, client_secret=connection_settings.client_secret.get_secret_value() if connection_settings.client_secret else None, client_certificate=Path(client_certificate) if client_certificate else None, user_assertion=user_assertion, ).acquire() return CopilotClient(connection_settings, token) # region Invocation Methods @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, ) -> AgentResponseItem[ChatMessageContent]: """Get a response from the agent. Args: messages: The messages to send to the agent. thread: The thread to use for the agent. arguments: The arguments to pass to the agent. These take precedence over the agent-defined args. kernel: The kernel to use for the agent. This kernel takes precedence over the agent-defined kernel. **kwargs: Additional keyword arguments. Returns: A chat message content and thread with the response. """ thread = await self._ensure_thread_exists_with_messages( messages=messages, thread=thread, construct_thread=lambda: CopilotStudioAgentThread(self.client), expected_type=CopilotStudioAgentThread, ) if not isinstance(thread, CopilotStudioAgentThread): raise AgentThreadOperationException("The thread is not a Copilot Studio Agent thread.") normalized_messages = self._normalize_messages(messages) responses: list[ChatMessageContent] = [] async for response in self._inner_invoke( thread=thread, messages=normalized_messages, on_intermediate_message=None, arguments=arguments, kernel=kernel, **kwargs, ): responses.append(response) 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, ) -> AsyncIterable[AgentResponseItem[ChatMessageContent]]: """Invoke the agent. Args: messages: The messages to send to the agent. thread: The thread to use for the agent. on_intermediate_message: A callback function to call with each intermediate message. arguments: The arguments to pass to the agent. kernel: The kernel to use for the agent. **kwargs: Additional keyword arguments. Yields: A chat message content and thread with the response. """ thread = await self._ensure_thread_exists_with_messages( messages=messages, thread=thread, construct_thread=lambda: CopilotStudioAgentThread(self.client), expected_type=CopilotStudioAgentThread, ) if not isinstance(thread, CopilotStudioAgentThread): raise AgentThreadOperationException("The thread is not a Copilot Studio Agent thread.") normalized_messages = self._normalize_messages(messages) async for response in self._inner_invoke( thread=thread, messages=normalized_messages, on_intermediate_message=on_intermediate_message, arguments=arguments, kernel=kernel, **kwargs, ): yield AgentResponseItem(message=response, thread=thread) @trace_agent_streaming_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 agent and stream the response. Note: this is a “pseudo-streaming” implementation. We're internally delegating to the real async generator `_inner_invoke`. Each complete ChatMessageContent is wrapped in exactly one StreamingChatMessageContent chunk, so downstream consumers can iterate without change. The stream yields at least once; callers still receive on_intermediate callbacks in real time. Args: messages: The messages to send to the agent. thread: The thread to use for the agent. on_intermediate_message: A callback function to call with each intermediate message. arguments: The arguments to pass to the agent. kernel: The kernel to use for the agent. **kwargs: Additional keyword arguments. Yields: A chat message content and thread with the response. """ thread = await self._ensure_thread_exists_with_messages( messages=messages, thread=thread, construct_thread=lambda: CopilotStudioAgentThread(self.client), expected_type=CopilotStudioAgentThread, ) if not isinstance(thread, CopilotStudioAgentThread): raise AgentThreadOperationException("The thread is not a Copilot Studio Agent thread.") normalized_messages = self._normalize_messages(messages) responses: list[ChatMessageContent] = [] async for resp in self._inner_invoke( thread=thread, messages=normalized_messages, on_intermediate_message=on_intermediate_message, arguments=arguments, kernel=kernel, **kwargs, ): responses.append(resp) for i, resp in enumerate(responses): stream_msg = self._to_streaming(resp, index=i) yield AgentResponseItem(message=stream_msg, thread=thread) # endregion # region Helper Methods async def _inner_invoke( self, thread: CopilotStudioAgentThread, messages: list[str] | None = None, on_intermediate_message: Callable[[ChatMessageContent], Awaitable[None]] | None = None, arguments: KernelArguments | None = None, kernel: "Kernel | None" = None, **kwargs: Any, ) -> AsyncIterable[ChatMessageContent]: if arguments is None: arguments = KernelArguments(**kwargs) else: arguments.update(kwargs) kernel = kernel or self.kernel arguments = self._merge_arguments(arguments) prompt_parts: list[str] = [] formatted_instructions = await self.format_instructions(kernel, arguments) if formatted_instructions: prompt_parts.append(formatted_instructions) if messages: prompt_parts.extend(messages) final_prompt: str = "\n".join(prompt_parts).strip() await self._ensure_conversation(thread) async for activity in self.client.ask_question(question=final_prompt, conversation_id=thread.id): if activity.type == ActivityTypes.message: if ( activity.text_format == "markdown" and activity.suggested_actions and activity.suggested_actions.actions ): for action in activity.suggested_actions.actions: if on_intermediate_message: await on_intermediate_message( ChatMessageContent(role=AuthorRole.ASSISTANT, name=self.name, content=action.text) ) yield ChatMessageContent(role=AuthorRole.ASSISTANT, name=self.name, content=activity.text) async def _ensure_conversation(self, thread: CopilotStudioAgentThread) -> None: """Guarantee that `thread.conversation_id` is populated.""" if thread.id: return async for act in self.client.start_conversation(): conversation_id = getattr(getattr(act, "conversation", None), "id", None) if conversation_id: thread.conversation_id = conversation_id return # If we reach this point, the service misbehaved, so throw raise AgentThreadOperationException("Copilot Studio did not return a conversation ID.") @staticmethod def _normalize_messages(messages: str | ChatMessageContent | list[str | ChatMessageContent] | None) -> list[str]: """Return a flat list[str] irrespective of the caller-supplied type.""" if messages is None: return [] if isinstance(messages, (str, ChatMessageContent)): messages = [messages] normalized: list[str] = [] for m in messages: normalized.append(m.content if isinstance(m, ChatMessageContent) else str(m)) return normalized @staticmethod def _to_streaming( msg: ChatMessageContent, *, index: int, ) -> StreamingChatMessageContent: """Wrap a complete ChatMessageContent in a StreamingChatMessageContent.""" return StreamingChatMessageContent( role=msg.role, name=msg.name, content=msg.content, choice_index=index, metadata=msg.metadata, ) @override async def _notify_thread_of_new_message(self, thread, new_message): """Copilot Studio Agent doesn't need to notify the thread of new messages. The new message is passed to the agent when invoking the agent. """ pass # endregion