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,28 @@
|
||||
# Copilot Studio Package (agent-framework-copilotstudio)
|
||||
|
||||
Integration with Microsoft Copilot Studio agents.
|
||||
|
||||
## Main Classes
|
||||
|
||||
- **`CopilotStudioAgent`** - Agent that connects to a Copilot Studio bot
|
||||
- **`acquire_token`** - Helper function for authentication
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from agent_framework.microsoft import CopilotStudioAgent
|
||||
|
||||
agent = CopilotStudioAgent(
|
||||
bot_identifier="your-bot-id",
|
||||
environment_id="your-env-id",
|
||||
)
|
||||
response = await agent.run("Hello")
|
||||
```
|
||||
|
||||
## Import Path
|
||||
|
||||
```python
|
||||
from agent_framework.microsoft import CopilotStudioAgent
|
||||
# or directly:
|
||||
from agent_framework_copilotstudio import CopilotStudioAgent
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,97 @@
|
||||
# Get Started with Microsoft Agent Framework Copilot Studio
|
||||
|
||||
Please install this package via pip:
|
||||
|
||||
```bash
|
||||
pip install agent-framework-copilotstudio --pre
|
||||
```
|
||||
|
||||
## Copilot Studio Agent
|
||||
|
||||
The Copilot Studio agent enables integration with Microsoft Copilot Studio, allowing you to interact with published copilots through the Agent Framework.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before using the Copilot Studio agent, you need:
|
||||
|
||||
1. **Copilot Studio Environment**: Access to a Microsoft Copilot Studio environment with a published copilot
|
||||
2. **App Registration**: An Azure AD App Registration with appropriate permissions for Power Platform API
|
||||
3. **Environment Configuration**: Set the required environment variables or pass them as parameters
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The following environment variables are used for configuration:
|
||||
|
||||
- `COPILOTSTUDIOAGENT__ENVIRONMENTID` - Your Copilot Studio environment ID
|
||||
- `COPILOTSTUDIOAGENT__SCHEMANAME` - Your copilot's agent identifier/schema name
|
||||
- `COPILOTSTUDIOAGENT__AGENTAPPID` - Your App Registration client ID
|
||||
- `COPILOTSTUDIOAGENT__TENANTID` - Your Azure AD tenant ID
|
||||
|
||||
### Basic Usage Example
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from agent_framework.microsoft import CopilotStudioAgent
|
||||
|
||||
async def main():
|
||||
# Create agent using environment variables
|
||||
agent = CopilotStudioAgent()
|
||||
|
||||
# Run a simple query
|
||||
result = await agent.run("What is the capital of France?")
|
||||
print(result)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Explicit Configuration Example
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import os
|
||||
from agent_framework.microsoft import CopilotStudioAgent, acquire_token
|
||||
from microsoft_agents.copilotstudio.client import ConnectionSettings, CopilotClient, PowerPlatformCloud, AgentType
|
||||
|
||||
async def main():
|
||||
# Acquire authentication token
|
||||
token = acquire_token(
|
||||
client_id=os.environ["COPILOTSTUDIOAGENT__AGENTAPPID"],
|
||||
tenant_id=os.environ["COPILOTSTUDIOAGENT__TENANTID"]
|
||||
)
|
||||
|
||||
# Create connection settings
|
||||
settings = ConnectionSettings(
|
||||
environment_id=os.environ["COPILOTSTUDIOAGENT__ENVIRONMENTID"],
|
||||
agent_identifier=os.environ["COPILOTSTUDIOAGENT__SCHEMANAME"],
|
||||
cloud=PowerPlatformCloud.PROD,
|
||||
copilot_agent_type=AgentType.PUBLISHED,
|
||||
custom_power_platform_cloud=None
|
||||
)
|
||||
|
||||
# Create client and agent
|
||||
client = CopilotClient(settings=settings, token=token)
|
||||
agent = CopilotStudioAgent(client=client)
|
||||
|
||||
# Run a query
|
||||
result = await agent.run("What is the capital of Italy?")
|
||||
print(result)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
The package uses MSAL (Microsoft Authentication Library) for authentication with interactive flows when needed. Ensure your App Registration has:
|
||||
|
||||
- **API Permissions**: Power Platform API permissions (https://api.powerplatform.com/.default)
|
||||
- **Redirect URIs**: Configured appropriately for your authentication method
|
||||
- **Public Client Flows**: Enabled if using interactive authentication
|
||||
|
||||
### Examples
|
||||
|
||||
For more comprehensive examples, see the [Copilot Studio examples](../../samples/02-agents/providers/copilotstudio/) which demonstrate:
|
||||
|
||||
- Basic non-streaming and streaming execution
|
||||
- Explicit settings and manual token acquisition
|
||||
- Different authentication patterns
|
||||
- Error handling and troubleshooting
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
[project]
|
||||
name = "agent-framework-copilotstudio"
|
||||
description = "Copilot Studio integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260709"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.11.0,<2",
|
||||
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = [
|
||||
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
|
||||
]
|
||||
timeout = 120
|
||||
markers = [
|
||||
"integration: marks tests as integration tests that require external services",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
include = ["agent_framework_copilotstudio"]
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_copilotstudio"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_copilotstudio"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_copilotstudio --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
@@ -0,0 +1,91 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from microsoft_agents.copilotstudio.client import CopilotClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exclude_list(request: Any) -> list[str]:
|
||||
"""Fixture that returns a list of environment variables to exclude."""
|
||||
return request.param if hasattr(request, "param") else []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def override_env_param_dict(request: Any) -> dict[str, str]:
|
||||
"""Fixture that returns a dict of environment variables to override."""
|
||||
return request.param if hasattr(request, "param") else {}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def copilot_studio_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore
|
||||
"""Fixture to set environment variables for CopilotStudioSettings."""
|
||||
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"COPILOTSTUDIOAGENT__ENVIRONMENTID": "test-environment-id",
|
||||
"COPILOTSTUDIOAGENT__SCHEMANAME": "test-schema-name",
|
||||
"COPILOTSTUDIOAGENT__AGENTAPPID": "test-client-id",
|
||||
"COPILOTSTUDIOAGENT__TENANTID": "test-tenant-id",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict) # type: ignore
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key in exclude_list:
|
||||
monkeypatch.delenv(key, raising=False) # type: ignore
|
||||
continue
|
||||
monkeypatch.setenv(key, value) # type: ignore
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_copilot_client() -> MagicMock:
|
||||
"""Mock CopilotClient for testing."""
|
||||
return MagicMock(spec=CopilotClient)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pca() -> MagicMock:
|
||||
"""Mock PublicClientApplication for testing."""
|
||||
mock_pca = MagicMock()
|
||||
|
||||
# Mock successful token response
|
||||
mock_token_response = {
|
||||
"access_token": "test-access-token-12345",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
|
||||
mock_pca.get_accounts.return_value = []
|
||||
mock_pca.acquire_token_interactive.return_value = mock_token_response
|
||||
mock_pca.acquire_token_silent.return_value = mock_token_response
|
||||
|
||||
return mock_pca
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_activity() -> MagicMock:
|
||||
"""Mock Activity for testing."""
|
||||
mock_activity = MagicMock()
|
||||
mock_activity.text = "Test response"
|
||||
mock_activity.type = "message"
|
||||
mock_activity.id = "test-activity-id"
|
||||
mock_activity.from_property.name = "Test Bot"
|
||||
return mock_activity
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_conversation() -> MagicMock:
|
||||
"""Mock conversation for testing."""
|
||||
mock_conversation = MagicMock()
|
||||
mock_conversation.id = "test-conversation-id"
|
||||
return mock_conversation
|
||||
@@ -0,0 +1,243 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework.exceptions import AgentException
|
||||
|
||||
from agent_framework_copilotstudio._acquire_token import DEFAULT_SCOPES, acquire_token
|
||||
|
||||
|
||||
class TestAcquireToken:
|
||||
"""Test class for token acquisition functionality."""
|
||||
|
||||
def test_acquire_token_missing_client_id(self) -> None:
|
||||
"""Test that acquire_token raises ValueError when client_id is missing."""
|
||||
with pytest.raises(ValueError, match="Client ID is required for token acquisition"):
|
||||
acquire_token(client_id="", tenant_id="test-tenant-id")
|
||||
|
||||
def test_acquire_token_missing_tenant_id(self) -> None:
|
||||
"""Test that acquire_token raises ValueError when tenant_id is missing."""
|
||||
with pytest.raises(ValueError, match="Tenant ID is required for token acquisition"):
|
||||
acquire_token(client_id="test-client-id", tenant_id="")
|
||||
|
||||
def test_acquire_token_none_client_id(self) -> None:
|
||||
"""Test that acquire_token raises ValueError when client_id is None."""
|
||||
with pytest.raises(ValueError, match="Client ID is required for token acquisition"):
|
||||
acquire_token(client_id=None, tenant_id="test-tenant-id") # type: ignore
|
||||
|
||||
def test_acquire_token_none_tenant_id(self) -> None:
|
||||
"""Test that acquire_token raises ValueError when tenant_id is None."""
|
||||
with pytest.raises(ValueError, match="Tenant ID is required for token acquisition"):
|
||||
acquire_token(client_id="test-client-id", tenant_id=None) # type: ignore
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_silent_success(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test successful silent token acquisition."""
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_pca.get_accounts.return_value = [mock_account]
|
||||
|
||||
mock_token_response = {"access_token": "test-access-token-12345"}
|
||||
mock_pca.acquire_token_silent.return_value = mock_token_response
|
||||
|
||||
result = acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
)
|
||||
|
||||
assert result == "test-access-token-12345"
|
||||
mock_pca_class.assert_called_once_with(
|
||||
client_id="test-client-id",
|
||||
authority="https://login.microsoftonline.com/test-tenant-id",
|
||||
token_cache=None,
|
||||
)
|
||||
mock_pca.get_accounts.assert_called_once_with(username=None)
|
||||
mock_pca.acquire_token_silent.assert_called_once_with(scopes=DEFAULT_SCOPES, account=mock_account)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_silent_success_with_username(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test successful silent token acquisition with username."""
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_pca.get_accounts.return_value = [mock_account]
|
||||
|
||||
mock_token_response = {"access_token": "test-access-token-12345"}
|
||||
mock_pca.acquire_token_silent.return_value = mock_token_response
|
||||
|
||||
result = acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
username="test-user@example.com",
|
||||
)
|
||||
|
||||
assert result == "test-access-token-12345"
|
||||
mock_pca.get_accounts.assert_called_once_with(username="test-user@example.com")
|
||||
mock_pca.acquire_token_silent.assert_called_once_with(scopes=DEFAULT_SCOPES, account=mock_account)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_silent_success_with_custom_scopes(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test successful silent token acquisition with custom scopes."""
|
||||
# Setup
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_pca.get_accounts.return_value = [mock_account]
|
||||
|
||||
mock_token_response = {"access_token": "test-access-token-12345"}
|
||||
mock_pca.acquire_token_silent.return_value = mock_token_response
|
||||
|
||||
custom_scopes = ["https://custom.api.com/.default"]
|
||||
|
||||
result = acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
scopes=custom_scopes,
|
||||
)
|
||||
|
||||
assert result == "test-access-token-12345"
|
||||
mock_pca.acquire_token_silent.assert_called_once_with(scopes=custom_scopes, account=mock_account)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_interactive_success_no_accounts(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test successful interactive token acquisition when no cached accounts exist."""
|
||||
# Setup
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_pca.get_accounts.return_value = [] # No cached accounts
|
||||
|
||||
mock_token_response = {"access_token": "test-interactive-token-67890"}
|
||||
mock_pca.acquire_token_interactive.return_value = mock_token_response
|
||||
|
||||
result = acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
)
|
||||
|
||||
assert result == "test-interactive-token-67890"
|
||||
mock_pca.acquire_token_interactive.assert_called_once_with(scopes=DEFAULT_SCOPES)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_fallback_to_interactive_after_silent_fails(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test fallback to interactive authentication when silent acquisition fails."""
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_pca.get_accounts.return_value = [mock_account]
|
||||
|
||||
# Silent acquisition fails with error response
|
||||
mock_silent_error_response = {"error": "invalid_grant", "error_description": "Token expired"}
|
||||
mock_pca.acquire_token_silent.return_value = mock_silent_error_response
|
||||
|
||||
# Interactive acquisition succeeds
|
||||
mock_interactive_response = {"access_token": "test-interactive-token-67890"}
|
||||
mock_pca.acquire_token_interactive.return_value = mock_interactive_response
|
||||
|
||||
result = acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
)
|
||||
|
||||
assert result == "test-interactive-token-67890"
|
||||
mock_pca.acquire_token_silent.assert_called_once_with(scopes=DEFAULT_SCOPES, account=mock_account)
|
||||
mock_pca.acquire_token_interactive.assert_called_once_with(scopes=DEFAULT_SCOPES)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_fallback_to_interactive_after_silent_exception(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test fallback to interactive authentication when silent acquisition throws exception."""
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_pca.get_accounts.return_value = [mock_account]
|
||||
|
||||
# Silent acquisition throws exception
|
||||
mock_pca.acquire_token_silent.side_effect = Exception("Network error")
|
||||
|
||||
# Interactive acquisition succeeds
|
||||
mock_interactive_response = {"access_token": "test-interactive-token-67890"}
|
||||
mock_pca.acquire_token_interactive.return_value = mock_interactive_response
|
||||
|
||||
result = acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
)
|
||||
|
||||
assert result == "test-interactive-token-67890"
|
||||
mock_pca.acquire_token_silent.assert_called_once_with(scopes=DEFAULT_SCOPES, account=mock_account)
|
||||
mock_pca.acquire_token_interactive.assert_called_once_with(scopes=DEFAULT_SCOPES)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_interactive_error_response(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test that acquire_token handles error responses from interactive authentication."""
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_pca.get_accounts.return_value = [] # No cached accounts
|
||||
|
||||
# Interactive acquisition returns error
|
||||
mock_error_response = {"error": "access_denied", "error_description": "User denied consent"}
|
||||
mock_pca.acquire_token_interactive.return_value = mock_error_response
|
||||
|
||||
with pytest.raises(AgentException, match="Authentication token cannot be acquired"):
|
||||
acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_interactive_exception(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test that acquire_token handles exceptions from interactive authentication."""
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_pca.get_accounts.return_value = [] # No cached accounts
|
||||
|
||||
# Interactive acquisition throws exception
|
||||
mock_pca.acquire_token_interactive.side_effect = Exception("Authentication service unavailable")
|
||||
|
||||
with pytest.raises(AgentException, match="Failed to acquire authentication token"):
|
||||
acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.PublicClientApplication")
|
||||
def test_acquire_token_with_token_cache(self, mock_pca_class: MagicMock) -> None:
|
||||
"""Test acquire_token with custom token cache."""
|
||||
mock_pca = MagicMock()
|
||||
mock_pca_class.return_value = mock_pca
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_pca.get_accounts.return_value = [mock_account]
|
||||
|
||||
mock_token_response = {"access_token": "test-cached-token"}
|
||||
mock_pca.acquire_token_silent.return_value = mock_token_response
|
||||
|
||||
mock_token_cache = MagicMock()
|
||||
|
||||
result = acquire_token(
|
||||
client_id="test-client-id",
|
||||
tenant_id="test-tenant-id",
|
||||
token_cache=mock_token_cache,
|
||||
)
|
||||
|
||||
assert result == "test-cached-token"
|
||||
mock_pca_class.assert_called_once_with(
|
||||
client_id="test-client-id",
|
||||
authority="https://login.microsoftonline.com/test-tenant-id",
|
||||
token_cache=mock_token_cache,
|
||||
)
|
||||
|
||||
def test_default_scopes_constant(self) -> None:
|
||||
"""Test that DEFAULT_SCOPES constant is properly defined."""
|
||||
assert DEFAULT_SCOPES == ["https://api.powerplatform.com/.default"]
|
||||
assert isinstance(DEFAULT_SCOPES, list)
|
||||
assert len(DEFAULT_SCOPES) == 1
|
||||
@@ -0,0 +1,361 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, AgentSession, Content, Message
|
||||
from agent_framework.exceptions import AgentException
|
||||
from microsoft_agents.copilotstudio.client import CopilotClient
|
||||
|
||||
from agent_framework_copilotstudio import CopilotStudioAgent
|
||||
|
||||
|
||||
def create_async_generator(items: list[Any]) -> Any:
|
||||
"""Helper to create async generator mock."""
|
||||
|
||||
async def async_gen() -> Any:
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
return async_gen()
|
||||
|
||||
|
||||
class TestCopilotStudioAgent:
|
||||
"""Test cases for CopilotStudioAgent."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_activity(self) -> MagicMock:
|
||||
activity = MagicMock()
|
||||
activity.text = "Test response"
|
||||
activity.type = "message"
|
||||
activity.id = "test-id"
|
||||
activity.from_property.name = "Test Bot"
|
||||
return activity
|
||||
|
||||
@pytest.fixture
|
||||
def mock_copilot_client(self) -> MagicMock:
|
||||
return MagicMock(spec=CopilotClient)
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
|
||||
@patch("agent_framework_copilotstudio._agent.load_settings")
|
||||
def test_init_missing_environment_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
|
||||
mock_acquire_token.return_value = "fake-token"
|
||||
mock_load_settings.return_value = {
|
||||
"environmentid": None,
|
||||
"schemaname": "test-bot",
|
||||
"tenantid": "test-tenant",
|
||||
"agentappid": "test-client",
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="environment ID is required"):
|
||||
CopilotStudioAgent()
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
|
||||
@patch("agent_framework_copilotstudio._agent.load_settings")
|
||||
def test_init_missing_bot_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
|
||||
mock_acquire_token.return_value = "fake-token"
|
||||
mock_load_settings.return_value = {
|
||||
"environmentid": "test-env",
|
||||
"schemaname": None,
|
||||
"tenantid": "test-tenant",
|
||||
"agentappid": "test-client",
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="agent identifier"):
|
||||
CopilotStudioAgent()
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
|
||||
@patch("agent_framework_copilotstudio._agent.load_settings")
|
||||
def test_init_missing_tenant_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
|
||||
mock_acquire_token.return_value = "fake-token"
|
||||
mock_load_settings.return_value = {
|
||||
"environmentid": "test-env",
|
||||
"schemaname": "test-bot",
|
||||
"tenantid": None,
|
||||
"agentappid": "test-client",
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="tenant ID is required"):
|
||||
CopilotStudioAgent()
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
|
||||
@patch("agent_framework_copilotstudio._agent.load_settings")
|
||||
def test_init_missing_client_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
|
||||
mock_acquire_token.return_value = "fake-token"
|
||||
mock_load_settings.return_value = {
|
||||
"environmentid": "test-env",
|
||||
"schemaname": "test-bot",
|
||||
"tenantid": "test-tenant",
|
||||
"agentappid": None,
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="client ID is required"):
|
||||
CopilotStudioAgent()
|
||||
|
||||
def test_init_with_client(self, mock_copilot_client: MagicMock) -> None:
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
assert agent.client == mock_copilot_client
|
||||
assert agent.id is not None
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
|
||||
def test_init_empty_environment_id(self, mock_acquire_token: MagicMock) -> None:
|
||||
mock_acquire_token.return_value = "fake-token"
|
||||
with patch("agent_framework_copilotstudio._agent.load_settings") as mock_load_settings:
|
||||
mock_load_settings.return_value = {
|
||||
"environmentid": "",
|
||||
"schemaname": "test-bot",
|
||||
"tenantid": "test-tenant",
|
||||
"agentappid": "test-client",
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="environment ID is required"):
|
||||
CopilotStudioAgent()
|
||||
|
||||
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
|
||||
def test_init_empty_schema_name(self, mock_acquire_token: MagicMock) -> None:
|
||||
mock_acquire_token.return_value = "fake-token"
|
||||
with patch("agent_framework_copilotstudio._agent.load_settings") as mock_load_settings:
|
||||
mock_load_settings.return_value = {
|
||||
"environmentid": "test-env",
|
||||
"schemaname": "",
|
||||
"tenantid": "test-tenant",
|
||||
"agentappid": "test-client",
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="agent identifier"):
|
||||
CopilotStudioAgent()
|
||||
|
||||
async def test_run_with_string_message(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None:
|
||||
"""Test run method with string message."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
conversation_activity = MagicMock()
|
||||
conversation_activity.conversation.id = "test-conversation-id"
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity])
|
||||
|
||||
response = await agent.run("test message")
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert len(response.messages) == 1
|
||||
content = response.messages[0].contents[0]
|
||||
assert content.type == "text"
|
||||
assert content.text == "Test response"
|
||||
assert response.messages[0].role == "assistant"
|
||||
|
||||
async def test_run_with_chat_message(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None:
|
||||
"""Test run method with Message."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
conversation_activity = MagicMock()
|
||||
conversation_activity.conversation.id = "test-conversation-id"
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity])
|
||||
|
||||
chat_message = Message(role="user", contents=[Content.from_text("test message")])
|
||||
response = await agent.run(chat_message)
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert len(response.messages) == 1
|
||||
content = response.messages[0].contents[0]
|
||||
assert content.type == "text"
|
||||
assert content.text == "Test response"
|
||||
assert response.messages[0].role == "assistant"
|
||||
|
||||
async def test_run_with_session(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None:
|
||||
"""Test run method with existing session."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
session = AgentSession()
|
||||
|
||||
conversation_activity = MagicMock()
|
||||
conversation_activity.conversation.id = "test-conversation-id"
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity])
|
||||
|
||||
response = await agent.run("test message", session=session)
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert len(response.messages) == 1
|
||||
assert session.service_session_id == "test-conversation-id"
|
||||
|
||||
async def test_run_start_conversation_failure(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run method when conversation start fails."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([])
|
||||
|
||||
with pytest.raises(AgentException, match="Failed to start a new conversation"):
|
||||
await agent.run("test message")
|
||||
|
||||
async def test_run_streaming_with_string_message(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run(stream=True) method with string message."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
conversation_activity = MagicMock()
|
||||
conversation_activity.conversation.id = "test-conversation-id"
|
||||
|
||||
typing_activity = MagicMock()
|
||||
typing_activity.text = "Streaming response"
|
||||
typing_activity.type = "typing"
|
||||
typing_activity.id = "test-typing-id"
|
||||
typing_activity.from_property.name = "Test Bot"
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([typing_activity])
|
||||
|
||||
response_count = 0
|
||||
async for response in agent.run("test message", stream=True):
|
||||
assert isinstance(response, AgentResponseUpdate)
|
||||
content = response.contents[0]
|
||||
assert content.type == "text"
|
||||
assert content.text == "Streaming response"
|
||||
response_count += 1
|
||||
|
||||
assert response_count == 1
|
||||
|
||||
async def test_run_streaming_with_session(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run(stream=True) method with existing session."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
session = AgentSession()
|
||||
|
||||
conversation_activity = MagicMock()
|
||||
conversation_activity.conversation.id = "test-conversation-id"
|
||||
|
||||
typing_activity = MagicMock()
|
||||
typing_activity.text = "Streaming response"
|
||||
typing_activity.type = "typing"
|
||||
typing_activity.id = "test-typing-id"
|
||||
typing_activity.from_property.name = "Test Bot"
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([typing_activity])
|
||||
|
||||
response_count = 0
|
||||
async for response in agent.run("test message", session=session, stream=True):
|
||||
assert isinstance(response, AgentResponseUpdate)
|
||||
content = response.contents[0]
|
||||
assert content.type == "text"
|
||||
assert content.text == "Streaming response"
|
||||
response_count += 1
|
||||
|
||||
assert response_count == 1
|
||||
assert session.service_session_id == "test-conversation-id"
|
||||
|
||||
async def test_run_reuses_existing_conversation(
|
||||
self, mock_copilot_client: MagicMock, mock_activity: MagicMock
|
||||
) -> None:
|
||||
"""Test run method reuses an existing conversation ID from the session."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
session = AgentSession()
|
||||
session.service_session_id = "existing-conversation-id"
|
||||
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity])
|
||||
|
||||
response = await agent.run("test message", session=session)
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert session.service_session_id == "existing-conversation-id"
|
||||
mock_copilot_client.start_conversation.assert_not_called()
|
||||
mock_copilot_client.ask_question.assert_called_once_with("test message", "existing-conversation-id")
|
||||
|
||||
async def test_run_streaming_reuses_existing_conversation(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run(stream=True) method reuses an existing conversation ID from the session."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
session = AgentSession()
|
||||
session.service_session_id = "existing-conversation-id"
|
||||
|
||||
typing_activity = MagicMock()
|
||||
typing_activity.text = "Streaming response"
|
||||
typing_activity.type = "typing"
|
||||
typing_activity.id = "test-typing-id"
|
||||
typing_activity.from_property.name = "Test Bot"
|
||||
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([typing_activity])
|
||||
|
||||
response_count = 0
|
||||
async for response in agent.run("test message", session=session, stream=True):
|
||||
assert isinstance(response, AgentResponseUpdate)
|
||||
response_count += 1
|
||||
|
||||
assert response_count == 1
|
||||
assert session.service_session_id == "existing-conversation-id"
|
||||
mock_copilot_client.start_conversation.assert_not_called()
|
||||
mock_copilot_client.ask_question.assert_called_once_with("test message", "existing-conversation-id")
|
||||
|
||||
async def test_run_streaming_no_typing_activity(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run(stream=True) method with non-typing activity."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
conversation_activity = MagicMock()
|
||||
conversation_activity.conversation.id = "test-conversation-id"
|
||||
|
||||
message_activity = MagicMock()
|
||||
message_activity.text = "Message response"
|
||||
message_activity.type = "message"
|
||||
message_activity.id = "test-message-id"
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([message_activity])
|
||||
|
||||
response_count = 0
|
||||
async for _response in agent.run("test message", stream=True):
|
||||
response_count += 1
|
||||
|
||||
assert response_count == 0
|
||||
|
||||
async def test_run_multiple_activities(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run method with multiple message activities."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
conversation_activity = MagicMock()
|
||||
conversation_activity.conversation.id = "test-conversation-id"
|
||||
|
||||
activity1 = MagicMock()
|
||||
activity1.text = "First response"
|
||||
activity1.type = "message"
|
||||
activity1.id = "test-id-1"
|
||||
activity1.from_property.name = "Test Bot"
|
||||
|
||||
activity2 = MagicMock()
|
||||
activity2.text = "Second response"
|
||||
activity2.type = "message"
|
||||
activity2.id = "test-id-2"
|
||||
activity2.from_property.name = "Test Bot"
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([activity1, activity2])
|
||||
|
||||
response = await agent.run("test message")
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert len(response.messages) == 2
|
||||
|
||||
async def test_run_list_of_messages(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None:
|
||||
"""Test run method with list of messages."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
conversation_activity = MagicMock()
|
||||
conversation_activity.conversation.id = "test-conversation-id"
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
|
||||
mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity])
|
||||
|
||||
messages = ["Hello", "How are you?"]
|
||||
response = await agent.run(messages)
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert len(response.messages) == 1
|
||||
|
||||
async def test_run_streaming_start_conversation_failure(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run(stream=True) method when conversation start fails."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
|
||||
mock_copilot_client.start_conversation.return_value = create_async_generator([])
|
||||
|
||||
with pytest.raises(AgentException, match="Failed to start a new conversation"):
|
||||
async for _ in agent.run("test message", stream=True):
|
||||
pass
|
||||
Reference in New Issue
Block a user