chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._acquire_token import acquire_token
|
||||
from ._agent import CopilotStudioAgent
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
__all__ = ["CopilotStudioAgent", "__version__", "acquire_token"]
|
||||
@@ -0,0 +1,95 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# pyright: reportUnknownMemberType = false
|
||||
# pyright: reportUnknownVariableType = false
|
||||
# pyright: reportUnknownArgumentType = false
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent_framework.exceptions import AgentException
|
||||
from msal import PublicClientApplication
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default scopes for Power Platform API
|
||||
DEFAULT_SCOPES = ["https://api.powerplatform.com/.default"]
|
||||
|
||||
|
||||
def acquire_token(
|
||||
*,
|
||||
client_id: str,
|
||||
tenant_id: str,
|
||||
username: str | None = None,
|
||||
token_cache: Any | None = None,
|
||||
scopes: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Acquire an authentication token using MSAL Public Client Application.
|
||||
|
||||
This function attempts to acquire a token silently first (using cached tokens),
|
||||
and falls back to interactive authentication if needed.
|
||||
|
||||
Keyword Args:
|
||||
client_id: The client ID of the application.
|
||||
tenant_id: The tenant ID for authentication.
|
||||
username: Optional username to filter accounts.
|
||||
token_cache: Optional token cache for storing tokens.
|
||||
scopes: Optional list of scopes. Defaults to Power Platform API scopes.
|
||||
|
||||
Returns:
|
||||
The access token string.
|
||||
|
||||
Raises:
|
||||
AgentException: If authentication token cannot be acquired.
|
||||
"""
|
||||
if not client_id:
|
||||
raise ValueError("Client ID is required for token acquisition.")
|
||||
|
||||
if not tenant_id:
|
||||
raise ValueError("Tenant ID is required for token acquisition.")
|
||||
|
||||
authority = f"https://login.microsoftonline.com/{tenant_id}"
|
||||
target_scopes = scopes or DEFAULT_SCOPES
|
||||
|
||||
pca = PublicClientApplication(client_id=client_id, authority=authority, token_cache=token_cache)
|
||||
|
||||
accounts = pca.get_accounts(username=username)
|
||||
|
||||
token: str | None = None
|
||||
|
||||
# Try silent token acquisition first if we have cached accounts
|
||||
if accounts:
|
||||
try:
|
||||
logger.debug("Attempting silent token acquisition")
|
||||
response = pca.acquire_token_silent(scopes=target_scopes, account=accounts[0])
|
||||
if response and "access_token" in response:
|
||||
token = str(response["access_token"])
|
||||
logger.debug("Successfully acquired token silently")
|
||||
elif response and "error" in response:
|
||||
logger.warning(
|
||||
"Silent token acquisition failed: %s - %s", response.get("error"), response.get("error_description")
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.warning("Silent token acquisition failed with exception: %s", ex)
|
||||
|
||||
# Fall back to interactive authentication if silent acquisition failed
|
||||
if not token:
|
||||
try:
|
||||
logger.debug("Attempting interactive token acquisition")
|
||||
response = pca.acquire_token_interactive(scopes=target_scopes)
|
||||
if response and "access_token" in response:
|
||||
token = str(response["access_token"])
|
||||
logger.debug("Successfully acquired token interactively")
|
||||
elif response and "error" in response:
|
||||
logger.error(
|
||||
"Interactive token acquisition failed: %s - %s",
|
||||
response.get("error"),
|
||||
response.get("error_description"),
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.error("Interactive token acquisition failed with exception: %s", ex)
|
||||
raise AgentException(f"Failed to acquire authentication token: {ex}") from ex
|
||||
|
||||
if not token:
|
||||
raise AgentException("Authentication token cannot be acquired.")
|
||||
|
||||
return token
|
||||
@@ -0,0 +1,348 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterable, Awaitable, Sequence
|
||||
from typing import Any, Literal, TypedDict, overload
|
||||
|
||||
from agent_framework import (
|
||||
AgentMiddlewareTypes,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentSession,
|
||||
BaseAgent,
|
||||
Content,
|
||||
ContextProvider,
|
||||
Message,
|
||||
ResponseStream,
|
||||
normalize_messages,
|
||||
)
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._types import AgentRunInputs
|
||||
from agent_framework.exceptions import AgentException
|
||||
from microsoft_agents.copilotstudio.client import AgentType, ConnectionSettings, CopilotClient, PowerPlatformCloud
|
||||
|
||||
from ._acquire_token import acquire_token
|
||||
|
||||
|
||||
class CopilotStudioSettings(TypedDict, total=False):
|
||||
"""Copilot Studio model settings.
|
||||
|
||||
Settings are resolved in this order: explicit keyword arguments, values from an
|
||||
explicitly provided .env file, then environment variables with the prefix
|
||||
'COPILOTSTUDIOAGENT__'.
|
||||
|
||||
Keys:
|
||||
environmentid: Environment ID of environment with the Copilot Studio App.
|
||||
Can be set via environment variable COPILOTSTUDIOAGENT__ENVIRONMENTID.
|
||||
schemaname: The agent identifier or schema name of the Copilot to use.
|
||||
Can be set via environment variable COPILOTSTUDIOAGENT__SCHEMANAME.
|
||||
agentappid: The app ID of the App Registration used to login.
|
||||
Can be set via environment variable COPILOTSTUDIOAGENT__AGENTAPPID.
|
||||
tenantid: The tenant ID of the App Registration used to login.
|
||||
Can be set via environment variable COPILOTSTUDIOAGENT__TENANTID.
|
||||
"""
|
||||
|
||||
environmentid: str | None
|
||||
schemaname: str | None
|
||||
agentappid: str | None
|
||||
tenantid: str | None
|
||||
|
||||
|
||||
class CopilotStudioAgent(BaseAgent):
|
||||
"""A Copilot Studio Agent."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: CopilotClient | None = None,
|
||||
settings: ConnectionSettings | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
context_providers: Sequence[ContextProvider] | None = None,
|
||||
middleware: list[AgentMiddlewareTypes] | None = None,
|
||||
environment_id: str | None = None,
|
||||
agent_identifier: str | None = None,
|
||||
client_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
token: str | None = None,
|
||||
cloud: PowerPlatformCloud | None = None,
|
||||
agent_type: AgentType | None = None,
|
||||
custom_power_platform_cloud: str | None = None,
|
||||
username: str | None = None,
|
||||
token_cache: Any | None = None,
|
||||
scopes: list[str] | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the Copilot Studio Agent.
|
||||
|
||||
Args:
|
||||
client: Optional pre-configured CopilotClient instance. If not provided,
|
||||
a new client will be created using the other parameters.
|
||||
settings: Optional pre-configured ConnectionSettings. If not provided,
|
||||
settings will be created from the other parameters.
|
||||
|
||||
Keyword Args:
|
||||
id: id of the CopilotAgent
|
||||
name: Name of the CopilotAgent
|
||||
description: Description of the CopilotAgent
|
||||
context_providers: Context Providers, to be used by the copilot agent.
|
||||
middleware: Agent middleware used by the agent, should be a list of AgentMiddlewareTypes.
|
||||
environment_id: Environment ID of the Power Platform environment containing
|
||||
the Copilot Studio app. Can also be set via COPILOTSTUDIOAGENT__ENVIRONMENTID
|
||||
environment variable.
|
||||
agent_identifier: The agent identifier or schema name of the Copilot to use.
|
||||
Can also be set via COPILOTSTUDIOAGENT__SCHEMANAME environment variable.
|
||||
client_id: The app ID of the App Registration used for authentication.
|
||||
Can also be set via COPILOTSTUDIOAGENT__AGENTAPPID environment variable.
|
||||
tenant_id: The tenant ID of the App Registration used for authentication.
|
||||
Can also be set via COPILOTSTUDIOAGENT__TENANTID environment variable.
|
||||
token: Optional pre-acquired authentication token. If not provided,
|
||||
token acquisition will be attempted using MSAL.
|
||||
cloud: The Power Platform cloud to use (Public, GCC, etc.).
|
||||
agent_type: The type of Copilot Studio agent (Copilot, Agent, etc.).
|
||||
custom_power_platform_cloud: Custom Power Platform cloud URL if using
|
||||
a custom environment.
|
||||
username: Optional username for token acquisition.
|
||||
token_cache: Optional token cache for storing authentication tokens.
|
||||
scopes: Optional list of authentication scopes. Defaults to Power Platform
|
||||
API scopes if not provided.
|
||||
env_file_path: Optional path to .env file for loading configuration.
|
||||
env_file_encoding: Encoding of the .env file, defaults to 'utf-8'.
|
||||
|
||||
Raises:
|
||||
ValueError: If required configuration is missing or invalid.
|
||||
"""
|
||||
super().__init__(
|
||||
id=id,
|
||||
name=name,
|
||||
description=description,
|
||||
context_providers=context_providers,
|
||||
middleware=middleware,
|
||||
)
|
||||
if not client:
|
||||
copilot_studio_settings = load_settings(
|
||||
CopilotStudioSettings,
|
||||
env_prefix="COPILOTSTUDIOAGENT__",
|
||||
environmentid=environment_id,
|
||||
schemaname=agent_identifier,
|
||||
agentappid=client_id,
|
||||
tenantid=tenant_id,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
resolved_environment_id = copilot_studio_settings.get("environmentid")
|
||||
resolved_agent_identifier = copilot_studio_settings.get("schemaname")
|
||||
resolved_client_id = copilot_studio_settings.get("agentappid")
|
||||
resolved_tenant_id = copilot_studio_settings.get("tenantid")
|
||||
|
||||
if not settings:
|
||||
if not resolved_environment_id:
|
||||
raise ValueError(
|
||||
"Copilot Studio environment ID is required. Set via 'environment_id' parameter "
|
||||
"or 'COPILOTSTUDIOAGENT__ENVIRONMENTID' environment variable."
|
||||
)
|
||||
if not resolved_agent_identifier:
|
||||
raise ValueError(
|
||||
"Copilot Studio agent identifier/schema name is required. Set via 'agent_identifier' parameter "
|
||||
"or 'COPILOTSTUDIOAGENT__SCHEMANAME' environment variable."
|
||||
)
|
||||
|
||||
settings = ConnectionSettings(
|
||||
environment_id=resolved_environment_id,
|
||||
agent_identifier=resolved_agent_identifier,
|
||||
cloud=cloud,
|
||||
copilot_agent_type=agent_type,
|
||||
custom_power_platform_cloud=custom_power_platform_cloud,
|
||||
)
|
||||
|
||||
if not token:
|
||||
if not resolved_client_id:
|
||||
raise ValueError(
|
||||
"Copilot Studio client ID is required. Set via 'client_id' parameter "
|
||||
"or 'COPILOTSTUDIOAGENT__AGENTAPPID' environment variable."
|
||||
)
|
||||
|
||||
if not resolved_tenant_id:
|
||||
raise ValueError(
|
||||
"Copilot Studio tenant ID is required. Set via 'tenant_id' parameter "
|
||||
"or 'COPILOTSTUDIOAGENT__TENANTID' environment variable."
|
||||
)
|
||||
|
||||
token = acquire_token(
|
||||
client_id=resolved_client_id,
|
||||
tenant_id=resolved_tenant_id,
|
||||
username=username,
|
||||
token_cache=token_cache,
|
||||
scopes=scopes,
|
||||
)
|
||||
|
||||
client = CopilotClient(settings=settings, token=token)
|
||||
|
||||
self.client = client
|
||||
self.cloud = cloud
|
||||
self.agent_type = agent_type
|
||||
self.custom_power_platform_cloud = custom_power_platform_cloud
|
||||
self.username = username
|
||||
self.token_cache = token_cache
|
||||
self.scopes = scopes
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
stream: Literal[False] = False,
|
||||
session: AgentSession | None = None,
|
||||
) -> Awaitable[AgentResponse]: ...
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
stream: Literal[True],
|
||||
session: AgentSession | None = None,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ...
|
||||
|
||||
def run(
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
"""Get a response from the agent.
|
||||
|
||||
This method returns the final result of the agent's execution
|
||||
as a single AgentResponse object. When stream=True, it returns
|
||||
a ResponseStream that yields AgentResponseUpdate objects.
|
||||
|
||||
Args:
|
||||
messages: The message(s) to send to the agent.
|
||||
|
||||
Keyword Args:
|
||||
stream: Whether to stream the response. Defaults to False.
|
||||
session: The conversation session associated with the message(s).
|
||||
|
||||
Returns:
|
||||
When stream=False: An Awaitable[AgentResponse].
|
||||
When stream=True: A ResponseStream of AgentResponseUpdate items.
|
||||
"""
|
||||
if stream:
|
||||
return self._run_stream_impl(messages=messages, session=session)
|
||||
return self._run_impl(messages=messages, session=session)
|
||||
|
||||
async def _run_impl(
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
session: AgentSession | None = None,
|
||||
) -> AgentResponse:
|
||||
"""Non-streaming implementation of run."""
|
||||
if not session:
|
||||
session = self.create_session()
|
||||
service_session_id = session.service_session_id
|
||||
if service_session_id is None:
|
||||
session.service_session_id = await self._start_new_conversation()
|
||||
service_session_id = session.service_session_id
|
||||
if not isinstance(service_session_id, str):
|
||||
raise AgentException("CopilotStudioAgent requires service_session_id to be a string")
|
||||
|
||||
input_messages = normalize_messages(messages)
|
||||
|
||||
question = "\n".join([message.text for message in input_messages])
|
||||
|
||||
activities = self.client.ask_question(question, service_session_id)
|
||||
response_messages: list[Message] = []
|
||||
response_id: str | None = None
|
||||
|
||||
response_messages = [message async for message in self._process_activities(activities, streaming=False)]
|
||||
response_id = response_messages[0].message_id if response_messages else None
|
||||
|
||||
return AgentResponse(messages=response_messages, response_id=response_id)
|
||||
|
||||
def _run_stream_impl(
|
||||
self,
|
||||
messages: AgentRunInputs | None = None,
|
||||
*,
|
||||
session: AgentSession | None = None,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
"""Streaming implementation of run."""
|
||||
|
||||
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
|
||||
nonlocal session
|
||||
if not session:
|
||||
session = self.create_session()
|
||||
service_session_id = session.service_session_id
|
||||
if service_session_id is None:
|
||||
session.service_session_id = await self._start_new_conversation()
|
||||
service_session_id = session.service_session_id
|
||||
if not isinstance(service_session_id, str):
|
||||
raise AgentException("CopilotStudioAgent requires service_session_id to be a string")
|
||||
|
||||
input_messages = normalize_messages(messages)
|
||||
|
||||
question = "\n".join([message.text for message in input_messages])
|
||||
|
||||
activities = self.client.ask_question(question, service_session_id)
|
||||
|
||||
async for message in self._process_activities(activities, streaming=True):
|
||||
yield AgentResponseUpdate(
|
||||
role=message.role,
|
||||
contents=message.contents,
|
||||
author_name=message.author_name,
|
||||
raw_representation=message.raw_representation,
|
||||
response_id=message.message_id,
|
||||
message_id=message.message_id,
|
||||
)
|
||||
|
||||
def _finalize(updates: Sequence[AgentResponseUpdate]) -> AgentResponse[None]:
|
||||
return AgentResponse.from_updates(updates)
|
||||
|
||||
return ResponseStream(_stream(), finalizer=_finalize)
|
||||
|
||||
async def _start_new_conversation(self) -> str:
|
||||
"""Start a new conversation with the Copilot Studio agent.
|
||||
|
||||
Returns:
|
||||
The conversation ID for the new conversation.
|
||||
|
||||
Raises:
|
||||
AgentException: If the conversation could not be started.
|
||||
"""
|
||||
conversation_id: str | None = None
|
||||
|
||||
async for activity in self.client.start_conversation(emit_start_conversation_event=True):
|
||||
if activity and activity.conversation and activity.conversation.id:
|
||||
conversation_id = activity.conversation.id
|
||||
|
||||
if not conversation_id:
|
||||
raise AgentException("Failed to start a new conversation.")
|
||||
|
||||
return conversation_id
|
||||
|
||||
async def _process_activities(self, activities: AsyncIterable[Any], streaming: bool) -> AsyncIterable[Message]:
|
||||
"""Process activities from the Copilot Studio agent.
|
||||
|
||||
Args:
|
||||
activities: Stream of activities from the agent.
|
||||
streaming: Whether to process activities for streaming (typing activities)
|
||||
or non-streaming (message activities) responses.
|
||||
|
||||
Yields:
|
||||
Message objects created from the activities.
|
||||
"""
|
||||
async for activity in activities:
|
||||
if activity.text and (
|
||||
(activity.type == "message" and not streaming) or (activity.type == "typing" and streaming)
|
||||
):
|
||||
yield Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_text(activity.text)],
|
||||
author_name=activity.from_property.name if activity.from_property else None,
|
||||
message_id=activity.id,
|
||||
raw_representation=activity,
|
||||
)
|
||||
Reference in New Issue
Block a user