Files
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

661 lines
26 KiB
Python

# 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