661 lines
26 KiB
Python
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
|