From 7f438b1fbb05762c2915a00dfca818e1355ad62d Mon Sep 17 00:00:00 2001 From: MattB Date: Sat, 14 Feb 2026 10:36:27 -0800 Subject: [PATCH 01/10] feat: Enhance PowerPlatformEnvironment with DirectConnect URL support and new request models - Added support for DirectConnect URL mode in PowerPlatformEnvironment, allowing simplified connection setup. - Introduced StartRequest model for initiating conversations with optional locale and conversation ID. - Created SubscribeEvent, SubscribeRequest, and SubscribeResponse models for handling subscription operations. - Implemented UserAgentHelper for generating user agent headers. - Updated README with new usage examples for DirectConnect URL and advanced configuration options. - Enhanced tests to cover new features and ensure functionality of DirectConnect URL handling. --- CLAUDE.md | 361 ++++++++++++++++ .../copilotstudio/client/__init__.py | 12 + .../client/connection_settings.py | 97 ++++- .../copilotstudio/client/copilot_client.py | 136 ++++++ .../client/copilot_client_protocol.py | 91 ++++ .../client/power_platform_environment.py | 295 +++++++++++-- .../copilotstudio/client/start_request.py | 26 ++ .../copilotstudio/client/subscribe_event.py | 21 + .../copilotstudio/client/subscribe_request.py | 12 + .../client/subscribe_response.py | 12 + .../copilotstudio/client/user_agent_helper.py | 38 ++ .../readme.md | 271 +++++++++++- .../chat_console_service.py | 32 +- .../copilot_studio_client_sample/config.py | 31 +- .../test_copilot_client.py | 409 ++++++++++++++++++ 15 files changed, 1749 insertions(+), 95 deletions(-) create mode 100644 CLAUDE.md create mode 100644 libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client_protocol.py create mode 100644 libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/start_request.py create mode 100644 libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/subscribe_event.py create mode 100644 libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/subscribe_request.py create mode 100644 libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/subscribe_response.py create mode 100644 libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/user_agent_helper.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..0c2e5e24 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,361 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository Overview + +This is the **Microsoft 365 Agents SDK for Python**, a framework for building enterprise-grade conversational agents for M365, Teams, Copilot Studio, and other platforms. The SDK replaces the legacy Bot Framework SDK (botbuilder packages) with a modern, modular architecture. + +**Important**: Python imports use underscores (`microsoft_agents`), not dots (`microsoft.agents`). + +## Development Setup + +### Initial Setup + +**Quick setup (Linux/macOS)**: +```bash +. ./scripts/dev_setup.sh +``` + +**Quick setup (Windows)**: +```bash +. ./scripts/dev_setup.ps1 +``` + +**Manual setup** (from repository root): +```bash +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# Install all libraries in editable mode +pip install -e ./libraries/microsoft-agents-activity/ --config-settings editable_mode=compat +pip install -e ./libraries/microsoft-agents-hosting-core/ --config-settings editable_mode=compat +pip install -e ./libraries/microsoft-agents-authentication-msal/ --config-settings editable_mode=compat +pip install -e ./libraries/microsoft-agents-copilotstudio-client/ --config-settings editable_mode=compat +pip install -e ./libraries/microsoft-agents-hosting-aiohttp/ --config-settings editable_mode=compat +pip install -e ./libraries/microsoft-agents-hosting-teams/ --config-settings editable_mode=compat +pip install -e ./libraries/microsoft-agents-storage-blob/ --config-settings editable_mode=compat +pip install -e ./libraries/microsoft-agents-storage-cosmos/ --config-settings editable_mode=compat +pip install -e ./libraries/microsoft-agents-hosting-fastapi/ --config-settings editable_mode=compat + +# Install development dependencies +pip install -r dev_dependencies.txt + +# Setup pre-commit hooks +pre-commit install +``` + +**Python version**: Requires Python 3.10+, supports 3.10-3.14. Recommended: Python 3.11+ + +## Common Commands + +### Testing + +```bash +# Run all tests +pytest + +# Run tests in a specific directory +pytest tests/microsoft-agents-activity/ + +# Run a single test file +pytest tests/microsoft-agents-activity/test_activity.py + +# Run a single test +pytest tests/microsoft-agents-activity/test_activity.py::test_activity_creation + +# Run with verbose output +pytest -v + +# Run with test markers +pytest -m unit +pytest -m integration +pytest -m slow +``` + +### Code Quality + +```bash +# Format code with black (line length: 88) +black libraries/ + +# Check formatting without making changes +black libraries/ --check + +# Lint with flake8 (max line length: 127, max complexity: 10) +flake8 . + +# Run pre-commit checks manually +pre-commit run --all-files +``` + +### Building Packages + +```bash +# Set package version (from versioning directory) +cd ./versioning +setuptools-git-versioning + +# Build all packages (run from repository root) +mkdir -p dist +for dir in libraries/*; do + if [ -f "$dir/pyproject.toml" ]; then + (cd "$dir" && python -m build --outdir ../../dist) + fi +done + +# Build a specific package +cd libraries/microsoft-agents-activity +python -m build +``` + +## Architecture Overview + +### Layer Structure + +The SDK follows a **layered, modular architecture** with clear separation of concerns: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Layer 5: Web Framework Adapters │ +│ hosting-aiohttp, hosting-fastapi │ +├─────────────────────────────────────────────────────────────────┤ +│ Layer 4: Platform Extensions & Storage │ +│ hosting-teams, storage-blob, storage-cosmos │ +├─────────────────────────────────────────────────────────────────┤ +│ Layer 3: Authentication │ +│ authentication-msal │ +├─────────────────────────────────────────────────────────────────┤ +│ Layer 2: Core Hosting Engine │ +│ hosting-core (Agent, TurnContext, State, Routing) │ +├─────────────────────────────────────────────────────────────────┤ +│ Layer 1: Protocol/Schema │ +│ activity (Activity protocol, Pydantic models) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Package Dependencies + +Each library in `libraries/` is independently published to PyPI: + +| Package | Purpose | Key Abstractions | +|---------|---------|------------------| +| `microsoft-agents-activity` | Activity protocol types using Pydantic | `Activity`, `ConversationReference`, protocols | +| `microsoft-agents-hosting-core` | Core agent runtime and lifecycle | `Agent`, `TurnContext`, `ActivityHandler`, `AgentApplication` | +| `microsoft-agents-authentication-msal` | MSAL-based OAuth authentication | `MsalAuth`, `MsalConnectionManager` | +| `microsoft-agents-hosting-aiohttp` | aiohttp web framework adapter | `CloudAdapter`, `start_agent_process()` | +| `microsoft-agents-hosting-fastapi` | FastAPI web framework adapter | `CloudAdapter`, `start_agent_process()` | +| `microsoft-agents-hosting-teams` | Teams-specific extensions | `TeamsActivityHandler`, `TeamsInfo` | +| `microsoft-agents-storage-blob` | Azure Blob Storage persistence | `BlobStorage` | +| `microsoft-agents-storage-cosmos` | CosmosDB persistence | `CosmosDbStorage` | + +### Two Programming Models + +**1. ActivityHandler (inheritance-based)**: +```python +class MyAgent(ActivityHandler): + async def on_message_activity(self, context: TurnContext): + await context.send_activity(f"You said: {context.activity.text}") +``` + +**2. AgentApplication (modern, decorator-based)**: +```python +app = AgentApplication[TurnState]() + +@app.message() +async def on_message(context: TurnContext[TurnState]): + await context.send_activity(f"You said: {context.activity.text}") +``` + +### Key Runtime Flow + +``` +HTTP POST /api/messages + ↓ +Web Framework (aiohttp/FastAPI) + ↓ +CloudAdapter.process() + ↓ +Parse Activity JSON → Create ClaimsIdentity + ↓ +ChannelServiceAdapter.process_activity() + ↓ +Create TurnContext(adapter, activity, identity) + ↓ +Middleware Pipeline (auth, logging, etc.) + ↓ +Agent.on_turn(context) + ↓ +Route to handler (ActivityHandler methods OR AgentApplication selectors) + ↓ +Handler executes, may call context.send_activity() + ↓ +Middleware unwind → Return response +``` + +### State Management + +Three state scopes managed via `TurnContext.state`: + +- **ConversationState**: Persisted per conversation (`conversation[conversation_id]`) +- **UserState**: Persisted per user across conversations (`user[user_id]`) +- **TempState**: Ephemeral, exists only for the current turn + +State is automatically loaded at turn start and saved at turn end using the configured `Storage` implementation. + +### Routing System (AgentApplication) + +The `AgentApplication` routing system prioritizes activities in this order: + +1. **Invoke activities** (Adaptive Cards, task modules) +2. **Agentic requests** (agent-to-agent communication) +3. **Regular messages** (user messages) + +Routes are matched using selectors: +- Activity type matching (`activity_types=["message"]`) +- Regex patterns (`message(r"^hello")`) +- Custom selector functions + +### Authentication Flow + +MSAL-based authentication supports three flows: +1. **User auth**: OAuth for user-to-agent +2. **Agentic user auth**: OAuth for agent-to-agent with user context +3. **Connector auth**: Service-to-service authentication + +OAuth state machine: +``` +User requests auth → Save _SignInState → Send OAuthCard + → User completes OAuth → Token callback → Retrieve token + → Resume conversation with auth +``` + +### Channel Service Clients + +The SDK dynamically selects the appropriate client based on channel context: + +- **Teams**: `TeamsConnectorClient` with Teams-specific APIs +- **Copilot Studio (MCS)**: `McsConnectorClient` with Direct-to-Engine +- **Generic**: `BotFrameworkConnectorClient` for standard channels + +Factory pattern in `RestChannelServiceClientFactory` handles this selection. + +## Important Architectural Patterns + +### Protocol-Oriented Design +All major abstractions use Python Protocols (structural typing) for loose coupling: +- `Agent`, `TurnContextProtocol`, `ChannelAdapterProtocol`, `Storage` + +### Adapter Pattern +- Framework adapters translate framework-specific types to SDK protocols +- `HttpRequestProtocol` abstracts HTTP request details +- `ChannelServiceAdapter` adapts between SDK and channel services + +### Middleware Pipeline +Cross-cutting concerns (auth, logging, state management) implemented as middleware: +```python +MiddlewareSet → Middleware 1 → ... → Agent Handler → Unwind +``` + +### Factory Pattern +- `ChannelServiceClientFactory`: Creates appropriate clients +- `MessageFactory`, `CardFactory`: Builders for creating messages + +## Code Style and Conventions + +- **Formatting**: `black` with 88-character line length (not 127 for flake8) +- **Linting**: `flake8` with max line length 127, max complexity 10 +- **Type hints**: Heavy use of generics and protocols +- **Async-first**: All I/O operations are async +- **Pydantic models**: Activity protocol uses Pydantic for validation +- **Error resources**: Standardized error codes with help URLs in `errors/` subdirectories + +## Testing Agents Locally + +### Prerequisites +1. Install [Microsoft Dev Tunnels](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started): + ```bash + winget install Microsoft.devtunnel + ``` + +2. Create and run tunnel: + ```bash + devtunnel user login + devtunnel create my-tunnel -a + devtunnel port create -p 3978 my-tunnel + devtunnel host -a my-tunnel + ``` + Record the tunnel URL for configuration. + +3. Install [M365 Agents Playground](https://github.com/OfficeDev/microsoft-365-agents-toolkit): + ```bash + winget install --id=Microsoft.M365AgentsPlayground -e + ``` + +### Running Samples + +Samples are in `test_samples/`: +- `app_style/`: Examples using `AgentApplication` +- `teams_agent/`: Teams-specific agent +- `fastapi/`: FastAPI integration example +- `agent_to_agent/`: Agent-to-agent communication + +Run samples from VS Code or directly: +```bash +cd test_samples/app_style +python empty_agent.py +``` + +## File Organization + +``` +libraries/ + microsoft-agents-activity/ + microsoft-agents-hosting-core/ + microsoft-agents-authentication-msal/ + microsoft-agents-hosting-{aiohttp,fastapi,teams}/ + microsoft-agents-storage-{blob,cosmos}/ + microsoft-agents-copilotstudio-client/ + +tests/ + {package-name}/ # Tests organized by package + +test_samples/ + app_style/ # AgentApplication examples + teams_agent/ # Teams examples + fastapi/ # FastAPI examples + agent_to_agent/ # Agent-to-agent examples + +scripts/ + dev_setup.sh # Linux/macOS dev setup + dev_setup.ps1 # Windows dev setup + +versioning/ + pyproject.toml # Centralized version management +``` + +## Common Pitfalls + +1. **Import structure**: Use `microsoft_agents` (underscores), not `microsoft.agents` (dots) +2. **Editable installs**: Always use `--config-settings editable_mode=compat` for editable installs +3. **Async everywhere**: All I/O operations are async, don't forget `await` +4. **State persistence**: State is auto-saved only if `Storage` is configured +5. **Activity responses**: Not all activities require a response (200), some return 202 Accepted +6. **Turn lifetime**: `TurnContext` is scoped to a single turn, don't cache it +7. **Middleware order**: Middleware runs in registration order, reverse on unwind + +## Debugging + +The SDK includes source code for debugging. Key areas for breakpoints: + +- **Request entry**: `CloudAdapter.process()` in hosting adapters +- **Activity parsing**: `ChannelServiceAdapter.process_activity()` +- **Turn context creation**: `TurnContext.__init__()` +- **Routing**: `AgentApplication._on_turn()` or `ActivityHandler.on_turn()` +- **State management**: `TurnState.load()` and `save()` +- **Activity sending**: `TurnContext.send_activity()` +- **Connector calls**: `ConnectorClient` methods in hosting-core + +## Release Process + +Versioning is centralized in `versioning/pyproject.toml` using `setuptools-git-versioning`. All packages share the same version number for consistency. diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/__init__.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/__init__.py index 250e50e6..4a22b806 100644 --- a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/__init__.py +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/__init__.py @@ -4,19 +4,31 @@ from .agent_type import AgentType from .connection_settings import ConnectionSettings from .copilot_client import CopilotClient +from .copilot_client_protocol import CopilotClientProtocol from .direct_to_engine_connection_settings_protocol import ( DirectToEngineConnectionSettingsProtocol, ) from .execute_turn_request import ExecuteTurnRequest from .power_platform_cloud import PowerPlatformCloud from .power_platform_environment import PowerPlatformEnvironment +from .start_request import StartRequest +from .subscribe_event import SubscribeEvent +from .subscribe_request import SubscribeRequest +from .subscribe_response import SubscribeResponse +from .user_agent_helper import UserAgentHelper __all__ = [ "AgentType", "ConnectionSettings", "CopilotClient", + "CopilotClientProtocol", "DirectToEngineConnectionSettingsProtocol", "ExecuteTurnRequest", "PowerPlatformCloud", "PowerPlatformEnvironment", + "StartRequest", + "SubscribeEvent", + "SubscribeRequest", + "SubscribeResponse", + "UserAgentHelper", ] diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/connection_settings.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/connection_settings.py index 777c545e..96909a10 100644 --- a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/connection_settings.py +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/connection_settings.py @@ -1,7 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Optional +from os import environ +from typing import Dict, Optional, Any from .direct_to_engine_connection_settings_protocol import ( DirectToEngineConnectionSettingsProtocol, ) @@ -22,6 +23,9 @@ def __init__( copilot_agent_type: Optional[AgentType] = None, custom_power_platform_cloud: Optional[str] = None, client_session_settings: Optional[dict] = None, + direct_connect_url: Optional[str] = None, + use_experimental_endpoint: bool = False, + enable_diagnostics: bool = False, ) -> None: """Initialize connection settings. @@ -32,17 +36,100 @@ def __init__( :param custom_power_platform_cloud: The custom PowerPlatformCloud URL. :param client_session_settings: Additional arguments for initialization of the underlying Aiohttp ClientSession. + :param direct_connect_url: Direct connection URL override. + :param use_experimental_endpoint: Flag to enable experimental endpoint. + :param enable_diagnostics: Flag to enable diagnostics. """ self.environment_id = environment_id self.agent_identifier = agent_identifier - if not self.environment_id: - raise ValueError("Environment ID must be provided") - if not self.agent_identifier: - raise ValueError("Agent Identifier must be provided") + if not self.environment_id and not direct_connect_url: + raise ValueError("Environment ID or Direct Connect URL must be provided") + if not self.agent_identifier and not direct_connect_url: + raise ValueError("Agent Identifier or Direct Connect URL must be provided") self.cloud = cloud or PowerPlatformCloud.PROD self.copilot_agent_type = copilot_agent_type or AgentType.PUBLISHED self.custom_power_platform_cloud = custom_power_platform_cloud self.client_session_settings = client_session_settings or {} + self.direct_connect_url = direct_connect_url + self.use_experimental_endpoint = use_experimental_endpoint + self.enable_diagnostics = enable_diagnostics + + @staticmethod + def populate_from_environment( + environment_id: Optional[str] = None, + agent_identifier: Optional[str] = None, + cloud: Optional[PowerPlatformCloud] = None, + copilot_agent_type: Optional[AgentType] = None, + custom_power_platform_cloud: Optional[str] = None, + direct_connect_url: Optional[str] = None, + use_experimental_endpoint: Optional[bool] = None, + enable_diagnostics: Optional[bool] = None, + ) -> Dict[str, Any]: + """ + Populate connection settings from environment variables. + + This method reads configuration values from environment variables + and returns them as a dictionary suitable for passing to ConnectionSettings.__init__(). + + :param environment_id: Optional override for ENVIRONMENT_ID env var. + :param agent_identifier: Optional override for AGENT_IDENTIFIER env var. + :param cloud: Optional override for CLOUD env var. + :param copilot_agent_type: Optional override for COPILOT_AGENT_TYPE env var. + :param custom_power_platform_cloud: Optional override for CUSTOM_POWER_PLATFORM_CLOUD env var. + :param direct_connect_url: Optional override for DIRECT_CONNECT_URL env var. + :param use_experimental_endpoint: Optional override for USE_EXPERIMENTAL_ENDPOINT env var. + :param enable_diagnostics: Optional override for ENABLE_DIAGNOSTICS env var. + :return: Dictionary of connection settings. + """ + # Read from environment variables with provided overrides + env_id = environment_id or environ.get("ENVIRONMENT_ID", "") + agent_id = agent_identifier or environ.get("AGENT_IDENTIFIER", "") + + # Handle cloud enum + cloud_value = cloud + if cloud_value is None: + cloud_str = environ.get("CLOUD", "PROD") + try: + cloud_value = PowerPlatformCloud[cloud_str] + except KeyError: + cloud_value = PowerPlatformCloud.PROD + + # Handle copilot agent type enum + agent_type_value = copilot_agent_type + if agent_type_value is None: + agent_type_str = environ.get("COPILOT_AGENT_TYPE", "PUBLISHED") + try: + agent_type_value = AgentType[agent_type_str] + except KeyError: + agent_type_value = AgentType.PUBLISHED + + # Handle other settings + custom_cloud = custom_power_platform_cloud or environ.get( + "CUSTOM_POWER_PLATFORM_CLOUD", None + ) + direct_url = direct_connect_url or environ.get("DIRECT_CONNECT_URL", None) + + # Handle boolean flags + exp_endpoint = use_experimental_endpoint + if exp_endpoint is None: + exp_endpoint = ( + environ.get("USE_EXPERIMENTAL_ENDPOINT", "false").lower() == "true" + ) + + diagnostics = enable_diagnostics + if diagnostics is None: + diagnostics = environ.get("ENABLE_DIAGNOSTICS", "false").lower() == "true" + + return { + "environment_id": env_id, + "agent_identifier": agent_id, + "cloud": cloud_value, + "copilot_agent_type": agent_type_value, + "custom_power_platform_cloud": custom_cloud, + "direct_connect_url": direct_url, + "use_experimental_endpoint": exp_endpoint, + "enable_diagnostics": diagnostics, + } diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py index 893bfb10..127fb49f 100644 --- a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py @@ -9,6 +9,9 @@ from .connection_settings import ConnectionSettings from .execute_turn_request import ExecuteTurnRequest from .power_platform_environment import PowerPlatformEnvironment +from .start_request import StartRequest +from .subscribe_event import SubscribeEvent +from .user_agent_helper import UserAgentHelper class CopilotClient: @@ -40,6 +43,8 @@ async def post_request( :param headers: The headers to be included in the POST request. :return: An asynchronous iterable of Activity objects received in the response. """ + # Add User-Agent header + headers["User-Agent"] = UserAgentHelper.get_user_agent_header() async with aiohttp.ClientSession( **self.settings.client_session_settings @@ -145,3 +150,134 @@ async def ask_question_with_activity( async for activity in self.post_request(url, data, headers): yield activity + + async def start_conversation_with_request( + self, start_request: StartRequest + ) -> AsyncIterable[Activity]: + """Start a new conversation with a StartRequest object. + + :param start_request: The StartRequest containing conversation parameters. + :return: An asynchronous iterable of Activity objects received in the response. + """ + + url = PowerPlatformEnvironment.get_copilot_studio_connection_url( + settings=self.settings + ) + data = start_request.model_dump(mode="json", by_alias=True, exclude_unset=True) + headers = { + "Content-Type": self.APPLICATION_JSON_TYPE, + "Authorization": f"Bearer {self._token}", + "Accept": self.EVENT_STREAM_TYPE, + } + + async for activity in self.post_request(url, data, headers): + yield activity + + async def send_activity(self, activity: Activity) -> AsyncIterable[Activity]: + """Send an activity to the bot. + + This is an alias for ask_question_with_activity for consistency with the .NET implementation. + + :param activity: The Activity object to send. + :return: An asynchronous iterable of Activity objects received in the response. + """ + async for result_activity in self.ask_question_with_activity(activity): + yield result_activity + + async def execute( + self, conversation_id: str, activity: Activity + ) -> AsyncIterable[Activity]: + """Execute an activity with a specified conversation ID. + + :param conversation_id: The conversation ID. + :param activity: The Activity object to execute. + :return: An asynchronous iterable of Activity objects received in the response. + """ + if not conversation_id: + raise ValueError("CopilotClient.execute: conversation_id cannot be None") + if not activity: + raise ValueError("CopilotClient.execute: activity cannot be None") + + # Set the conversation ID on the activity + if not activity.conversation: + activity.conversation = ConversationAccount(id=conversation_id) + else: + activity.conversation.id = conversation_id + + async for result_activity in self.ask_question_with_activity(activity): + yield result_activity + + async def subscribe( + self, conversation_id: str, last_received_event_id: Optional[str] = None + ) -> AsyncIterable[SubscribeEvent]: + """Subscribe to conversation events. + + Note: This method is marked as obsolete in the .NET implementation and is for MSFT internal use only. + + :param conversation_id: The conversation ID to subscribe to. + :param last_received_event_id: Optional last received event ID for resumption. + :return: An asynchronous iterable of SubscribeEvent objects. + """ + if not conversation_id: + raise ValueError("CopilotClient.subscribe: conversation_id cannot be None") + + # Build the subscribe URL + url = PowerPlatformEnvironment.get_copilot_studio_connection_url( + settings=self.settings, conversation_id=conversation_id + ) + + # Append /subscribe to the URL + url = url.replace("/conversations/", "/subscribe/") + + headers = { + "Content-Type": self.APPLICATION_JSON_TYPE, + "Authorization": f"Bearer {self._token}", + "Accept": self.EVENT_STREAM_TYPE, + } + + # Add Last-Event-ID header if provided + if last_received_event_id: + headers["Last-Event-ID"] = last_received_event_id + + # Add User-Agent header + headers["User-Agent"] = UserAgentHelper.get_user_agent_header() + + async with aiohttp.ClientSession( + **self.settings.client_session_settings + ) as session: + async with session.get(url, headers=headers) as response: + + if response.status != 200: + raise aiohttp.ClientError( + f"Error subscribing to conversation: {response.status}" + ) + + event_id = None + event_type = None + async for line in response.content: + if line.startswith(b"id:"): + event_id = line[3:].decode("utf-8").strip() + elif line.startswith(b"event:"): + event_type = line[6:].decode("utf-8").strip() + elif line.startswith(b"data:") and event_type == "activity": + activity_data = line[5:].decode("utf-8").strip() + activity = Activity.model_validate_json(activity_data) + yield SubscribeEvent(activity=activity, event_id=event_id) + + @staticmethod + def scope_from_settings(settings: ConnectionSettings) -> str: + """Get the token audience scope from connection settings. + + :param settings: The ConnectionSettings object. + :return: The token audience scope URL. + """ + return PowerPlatformEnvironment.get_token_audience(settings=settings) + + @staticmethod + def scope_from_cloud(cloud) -> str: + """Get the token audience scope from PowerPlatformCloud. + + :param cloud: The PowerPlatformCloud value. + :return: The token audience scope URL. + """ + return PowerPlatformEnvironment.get_token_audience(cloud=cloud) diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client_protocol.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client_protocol.py new file mode 100644 index 00000000..0aff6a41 --- /dev/null +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client_protocol.py @@ -0,0 +1,91 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import AsyncIterable, Optional, Protocol +from microsoft_agents.activity import Activity +from .subscribe_event import SubscribeEvent +from .start_request import StartRequest + + +class CopilotClientProtocol(Protocol): + """ + Protocol defining the contract for a client that connects to the Direct-to-Engine API endpoint for Copilot Studio. + """ + + async def start_conversation( + self, emit_start_conversation_event: bool = True + ) -> AsyncIterable[Activity]: + """ + Start a new conversation. + + :param emit_start_conversation_event: Whether to emit a start conversation event. + :return: An asynchronous iterable of Activity objects. + """ + ... + + async def start_conversation_with_request( + self, start_request: StartRequest + ) -> AsyncIterable[Activity]: + """ + Start a new conversation with a StartRequest. + + :param start_request: The StartRequest containing conversation parameters. + :return: An asynchronous iterable of Activity objects. + """ + ... + + async def ask_question( + self, question: str, conversation_id: Optional[str] = None + ) -> AsyncIterable[Activity]: + """ + Ask a question in a conversation. + + :param question: The question text. + :param conversation_id: Optional conversation ID. + :return: An asynchronous iterable of Activity objects. + """ + ... + + async def ask_question_with_activity( + self, activity: Activity + ) -> AsyncIterable[Activity]: + """ + Ask a question using an Activity object. + + :param activity: The activity to send. + :return: An asynchronous iterable of Activity objects. + """ + ... + + async def send_activity(self, activity: Activity) -> AsyncIterable[Activity]: + """ + Send an activity to the bot. + + :param activity: The activity to send. + :return: An asynchronous iterable of Activity objects. + """ + ... + + async def execute( + self, conversation_id: str, activity: Activity + ) -> AsyncIterable[Activity]: + """ + Execute an activity with a specified conversation ID. + + :param conversation_id: The conversation ID. + :param activity: The activity to execute. + :return: An asynchronous iterable of Activity objects. + """ + ... + + async def subscribe( + self, conversation_id: str, last_received_event_id: Optional[str] = None + ) -> AsyncIterable[SubscribeEvent]: + """ + Subscribe to conversation events. + + :param conversation_id: The conversation ID to subscribe to. + :param last_received_event_id: Optional last received event ID for resumption. + :return: An asynchronous iterable of SubscribeEvent objects. + """ + ... diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py index 0afc31ee..057b0b4f 100644 --- a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py @@ -23,67 +23,155 @@ def get_copilot_studio_connection_url( conversation_id: Optional[str] = None, agent_type: AgentType = AgentType.PUBLISHED, cloud: PowerPlatformCloud = PowerPlatformCloud.PROD, + create_subscribe_link: bool = False, cloud_base_address: Optional[str] = None, + direct_connect_url: Optional[str] = None, ) -> str: - if cloud == PowerPlatformCloud.OTHER and not cloud_base_address: - raise ValueError(str(copilot_studio_errors.CloudBaseAddressRequired)) - if not settings.environment_id: - raise ValueError(str(copilot_studio_errors.EnvironmentIdRequired)) - if not settings.agent_identifier: - raise ValueError(str(copilot_studio_errors.AgentIdentifierRequired)) - if settings.cloud and settings.cloud != PowerPlatformCloud.UNKNOWN: - cloud = settings.cloud - if cloud == PowerPlatformCloud.OTHER: - parsed_url = urlparse(cloud_base_address) - is_absolute_url = parsed_url.scheme and parsed_url.netloc - if cloud_base_address and is_absolute_url: - pass - elif settings.custom_power_platform_cloud: - cloud_base_address = settings.custom_power_platform_cloud - else: - raise ValueError( - str(copilot_studio_errors.CustomCloudOrBaseAddressRequired) + """ + Gets the Power Platform API connection URL for the given settings. + + :param settings: Configuration Settings to use + :param conversation_id: Optional, Conversation ID to address + :param agent_type: Type of Agent being addressed + :param cloud: Power Platform Cloud Hosting Agent + :param create_subscribe_link: Whether to create a subscribe link for the conversation + :param cloud_base_address: Power Platform API endpoint to use if Cloud is configured as "other" + :param direct_connect_url: DirectConnection URL to a given Copilot Studio agent, if provided all other settings are ignored + :return: Connection URL string + """ + # Check if using DirectConnect URL mode + direct_url = direct_connect_url or settings.direct_connect_url + + if not direct_url: + # Standard environment-based connection + if cloud == PowerPlatformCloud.OTHER and not cloud_base_address: + raise ValueError(str(copilot_studio_errors.CloudBaseAddressRequired)) + if not settings.environment_id: + raise ValueError(str(copilot_studio_errors.EnvironmentIdRequired)) + if not settings.agent_identifier: + raise ValueError(str(copilot_studio_errors.AgentIdentifierRequired)) + if settings.cloud and settings.cloud != PowerPlatformCloud.UNKNOWN: + cloud = settings.cloud + if cloud == PowerPlatformCloud.OTHER: + parsed_url = ( + urlparse(cloud_base_address) if cloud_base_address else None ) - if settings.copilot_agent_type: - agent_type = settings.copilot_agent_type + is_absolute_url = parsed_url and parsed_url.scheme and parsed_url.netloc + if cloud_base_address and is_absolute_url: + cloud = PowerPlatformCloud.OTHER + elif settings.custom_power_platform_cloud: + parsed_custom = urlparse(settings.custom_power_platform_cloud) + if parsed_custom.scheme or parsed_custom.netloc: + cloud = PowerPlatformCloud.OTHER + cloud_base_address = settings.custom_power_platform_cloud + else: + raise ValueError( + str(copilot_studio_errors.CustomCloudOrBaseAddressRequired) + ) + else: + raise ValueError( + str(copilot_studio_errors.CustomCloudOrBaseAddressRequired) + ) + if settings.copilot_agent_type: + agent_type = settings.copilot_agent_type - cloud_base_address = cloud_base_address or "api.unknown.powerplatform.com" - host = PowerPlatformEnvironment.get_environment_endpoint( - cloud, settings.environment_id, cloud_base_address - ) - return PowerPlatformEnvironment.create_uri( - settings.agent_identifier, host, agent_type, conversation_id - ) + cloud_base_address = cloud_base_address or "api.unknown.powerplatform.com" + host = PowerPlatformEnvironment.get_environment_endpoint( + cloud, settings.environment_id, cloud_base_address + ) + return PowerPlatformEnvironment._create_uri_standard( + settings.agent_identifier, + host, + agent_type, + conversation_id, + create_subscribe_link, + ) + else: + # DirectConnect URL mode + parsed_direct = urlparse(direct_url) + if not (parsed_direct.scheme and parsed_direct.netloc): + raise ValueError("DirectConnectUrl is invalid") + return PowerPlatformEnvironment._create_uri_direct( + direct_url, conversation_id, create_subscribe_link + ) @staticmethod def get_token_audience( settings: Optional[ConnectionSettings] = None, cloud: PowerPlatformCloud = PowerPlatformCloud.UNKNOWN, cloud_base_address: Optional[str] = None, + direct_connect_url: Optional[str] = None, ) -> str: - if cloud == PowerPlatformCloud.OTHER and not cloud_base_address: - raise ValueError(str(copilot_studio_errors.CloudBaseAddressRequired)) - if not settings and cloud == PowerPlatformCloud.UNKNOWN: - raise ValueError("Either settings or cloud must be provided") - if settings and settings.cloud and settings.cloud != PowerPlatformCloud.UNKNOWN: - cloud = settings.cloud - if cloud == PowerPlatformCloud.OTHER: - if cloud_base_address and urlparse(cloud_base_address).scheme: - cloud = PowerPlatformCloud.OTHER - elif ( + """ + Returns the Power Platform API Audience. + + :param settings: Configuration Settings to use + :param cloud: Power Platform Cloud Hosting Agent + :param cloud_base_address: Power Platform API endpoint to use if Cloud is configured as "other" + :param direct_connect_url: DirectConnection URL to a given Copilot Studio agent + :return: Token audience string + """ + # Check if using DirectConnect URL mode + direct_url = direct_connect_url or ( + settings.direct_connect_url if settings else None + ) + + if not direct_url: + # Standard environment-based audience + if cloud == PowerPlatformCloud.OTHER and not cloud_base_address: + raise ValueError(str(copilot_studio_errors.CloudBaseAddressRequired)) + if not settings and cloud == PowerPlatformCloud.UNKNOWN: + raise ValueError("Either settings or cloud must be provided") + if ( settings - and settings.custom_power_platform_cloud - and urlparse(settings.custom_power_platform_cloud).scheme + and settings.cloud + and settings.cloud != PowerPlatformCloud.UNKNOWN ): - cloud = PowerPlatformCloud.OTHER - cloud_base_address = settings.custom_power_platform_cloud - else: + cloud = settings.cloud + if cloud == PowerPlatformCloud.OTHER: + if cloud_base_address and urlparse(cloud_base_address).scheme: + cloud = PowerPlatformCloud.OTHER + elif ( + settings + and settings.custom_power_platform_cloud + and urlparse(settings.custom_power_platform_cloud).scheme + ): + cloud = PowerPlatformCloud.OTHER + cloud_base_address = settings.custom_power_platform_cloud + else: + raise ValueError( + str(copilot_studio_errors.CustomCloudOrBaseAddressRequired) + ) + + cloud_base_address = cloud_base_address or "api.unknown.powerplatform.com" + return f"https://{PowerPlatformEnvironment.get_endpoint_suffix(cloud, cloud_base_address)}/.default" + else: + # DirectConnect URL mode + parsed_direct = urlparse(direct_url) + if not (parsed_direct.scheme and parsed_direct.netloc): raise ValueError( - str(copilot_studio_errors.CustomCloudOrBaseAddressRequired) + "DirectConnectUrl must be provided when DirectConnectUrl is set" ) - cloud_base_address = cloud_base_address or "api.unknown.powerplatform.com" - return f"https://{PowerPlatformEnvironment.get_endpoint_suffix(cloud, cloud_base_address)}/.default" + decoded_cloud = PowerPlatformEnvironment._decode_cloud_from_uri(direct_url) + if decoded_cloud == PowerPlatformCloud.UNKNOWN: + cloud_to_test = settings.cloud if settings else cloud + if ( + cloud_to_test == PowerPlatformCloud.OTHER + or cloud_to_test == PowerPlatformCloud.UNKNOWN + ): + raise ValueError( + "Unable to resolve the PowerPlatform Cloud from DirectConnectUrl. " + "The Token Audience resolver requires a specific PowerPlatformCloudCategory." + ) + if cloud_to_test != PowerPlatformCloud.UNKNOWN: + return f"https://{PowerPlatformEnvironment.get_endpoint_suffix(cloud_to_test, '')}/.default" + else: + raise ValueError( + "Unable to resolve the PowerPlatform Cloud from DirectConnectUrl. " + "The Token Audience resolver requires a specific PowerPlatformCloudCategory." + ) + return f"https://{PowerPlatformEnvironment.get_endpoint_suffix(decoded_cloud, '')}/.default" @staticmethod def create_uri( @@ -91,13 +179,51 @@ def create_uri( host: str, agent_type: AgentType, conversation_id: Optional[str], + create_subscribe_link: bool = False, + ) -> str: + """ + Creates the PowerPlatform API connection URL for the given settings. + This is a compatibility method that calls _create_uri_standard. + + :param agent_identifier: The agent/bot identifier + :param host: The host address + :param agent_type: Type of agent (Published or Prebuilt) + :param conversation_id: Optional conversation ID + :param create_subscribe_link: Whether to create a subscribe link + :return: Connection URL string + """ + return PowerPlatformEnvironment._create_uri_standard( + agent_identifier, host, agent_type, conversation_id, create_subscribe_link + ) + + @staticmethod + def _create_uri_standard( + agent_identifier: str, + host: str, + agent_type: AgentType, + conversation_id: Optional[str], + create_subscribe_link: bool = False, ) -> str: + """ + Creates the PowerPlatform API connection URL for standard environment-based connections. + + :param agent_identifier: The agent/bot identifier (schema name) + :param host: The host address + :param agent_type: Type of agent (Published or Prebuilt) + :param conversation_id: Optional conversation ID + :param create_subscribe_link: Whether to create a subscribe link + :return: Connection URL string + """ agent_path_name = ( "dataverse-backed" if agent_type == AgentType.PUBLISHED else "prebuilt" ) - path = f"/copilotstudio/{agent_path_name}/authenticated/bots/{agent_identifier}/conversations" - if conversation_id: - path += f"/{conversation_id}" + + if not conversation_id: + path = f"/copilotstudio/{agent_path_name}/authenticated/bots/{agent_identifier}/conversations" + else: + conversation_suffix = "/subscribe" if create_subscribe_link else "" + path = f"/copilotstudio/{agent_path_name}/authenticated/bots/{agent_identifier}/conversations/{conversation_id}{conversation_suffix}" + return urlunparse( ( "https", @@ -109,6 +235,79 @@ def create_uri( ) ) + @staticmethod + def _create_uri_direct( + base_address: str, + conversation_id: Optional[str], + create_subscribe_link: bool = False, + ) -> str: + """ + Creates the PowerPlatform API connection URL using a DirectConnect URL. + Used only when DirectConnectUrl is provided. + + :param base_address: The direct connect base URL + :param conversation_id: Optional conversation ID + :param create_subscribe_link: Whether to create a subscribe link + :return: Connection URL string + """ + parsed = urlparse(base_address) + + # Remove trailing slashes + path = parsed.path + while path.endswith("/") or path.endswith("\\"): + path = path[:-1] + + # If path has /conversations, remove it + if "/conversations" in path: + path = path[: path.index("/conversations")] + + # Build the new path + if not conversation_id: + path = f"{path}/conversations" + else: + if create_subscribe_link: + path = f"{path}/conversations/{conversation_id}/subscribe" + else: + path = f"{path}/conversations/{conversation_id}" + + return urlunparse( + ( + parsed.scheme, + parsed.netloc, + path, + "", + f"api-version={PowerPlatformEnvironment.API_VERSION}", + "", + ) + ) + + @staticmethod + def _decode_cloud_from_uri(uri: str) -> PowerPlatformCloud: + """ + Decode the PowerPlatformCloud from a DirectConnect URL. + + :param uri: The URL to decode + :return: The PowerPlatformCloud enum value + """ + parsed = urlparse(uri) + host = parsed.hostname.lower() if parsed.hostname else "" + + cloud_mapping = { + "api.powerplatform.localhost": PowerPlatformCloud.LOCAL, + "api.exp.powerplatform.com": PowerPlatformCloud.EXP, + "api.dev.powerplatform.com": PowerPlatformCloud.DEV, + "api.prv.powerplatform.com": PowerPlatformCloud.PRV, + "api.test.powerplatform.com": PowerPlatformCloud.TEST, + "api.preprod.powerplatform.com": PowerPlatformCloud.PREPROD, + "api.powerplatform.com": PowerPlatformCloud.PROD, + "api.gov.powerplatform.microsoft.us": PowerPlatformCloud.GOV_FR, + "api.high.powerplatform.microsoft.us": PowerPlatformCloud.HIGH, + "api.appsplatform.us": PowerPlatformCloud.DOD, + "api.powerplatform.partner.microsoftonline.cn": PowerPlatformCloud.MOONCAKE, + } + + return cloud_mapping.get(host, PowerPlatformCloud.UNKNOWN) + @staticmethod def get_environment_endpoint( cloud: PowerPlatformCloud, diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/start_request.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/start_request.py new file mode 100644 index 00000000..71edac8b --- /dev/null +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/start_request.py @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Optional +from microsoft_agents.activity import AgentsModel +from pydantic import Field + + +class StartRequest(AgentsModel): + """ + Request model for starting a conversation with Copilot Studio. + """ + + locale: Optional[str] = Field( + default=None, description="The locale to use as defined by the client" + ) + emit_start_conversation_event: bool = Field( + default=True, + alias="emitStartConversationEvent", + description="Whether to emit a StartConversation event", + ) + conversation_id: Optional[str] = Field( + default=None, + alias="conversationId", + description="Conversation ID requested by the client", + ) diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/subscribe_event.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/subscribe_event.py new file mode 100644 index 00000000..202a9686 --- /dev/null +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/subscribe_event.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Optional +from microsoft_agents.activity import Activity + + +class SubscribeEvent: + """ + Represents a subscription event containing an activity and optional SSE event ID. + """ + + def __init__(self, activity: Activity, event_id: Optional[str] = None): + """ + Initialize a SubscribeEvent. + + :param activity: The activity received from the copilot. + :param event_id: The SSE event ID for resumption (None for JSON responses). + """ + self.activity = activity + self.event_id = event_id diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/subscribe_request.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/subscribe_request.py new file mode 100644 index 00000000..b470cfb3 --- /dev/null +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/subscribe_request.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.activity import AgentsModel + + +class SubscribeRequest(AgentsModel): + """ + Request model for subscribe operations. + """ + + pass diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/subscribe_response.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/subscribe_response.py new file mode 100644 index 00000000..4caee089 --- /dev/null +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/subscribe_response.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.activity import AgentsModel + + +class SubscribeResponse(AgentsModel): + """ + Response model for subscribe operations. + """ + + pass diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/user_agent_helper.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/user_agent_helper.py new file mode 100644 index 00000000..b66d59a6 --- /dev/null +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/user_agent_helper.py @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import sys +import platform + + +class UserAgentHelper: + """ + Helper class for generating user agent headers. + """ + + CLIENT_NAME = "CopilotStudioClient" + CLIENT_VERSION = "0.8.0" # Should match package version + + @staticmethod + def get_user_agent_header() -> str: + """ + Generate a user agent header string. + + :return: User agent header string in the format: + "ClientName.agents-sdk-python/version Python/version OS/version" + """ + # Get Python version + python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + + # Get OS information + os_name = platform.system() + os_version = platform.release() + + # Construct user agent string + user_agent = ( + f"{UserAgentHelper.CLIENT_NAME}.agents-sdk-python/{UserAgentHelper.CLIENT_VERSION} " + f"Python/{python_version} " + f"{os_name}/{os_version}" + ) + + return user_agent diff --git a/libraries/microsoft-agents-copilotstudio-client/readme.md b/libraries/microsoft-agents-copilotstudio-client/readme.md index 65079e06..68cc8528 100644 --- a/libraries/microsoft-agents-copilotstudio-client/readme.md +++ b/libraries/microsoft-agents-copilotstudio-client/readme.md @@ -85,6 +85,8 @@ pip install microsoft-agents-copilotstudio-client ### Basic Setup +#### Standard Environment-Based Connection + Code below from the [main.py in the Copilot Studio Client](https://github.com/microsoft/Agents/blob/main/samples/python/copilotstudio-client/src/main.py) ```python def create_client(): @@ -104,64 +106,295 @@ def create_client(): return copilot_client ``` +#### DirectConnect URL Mode (Simplified Setup) + +For simplified setup, you can use a DirectConnect URL instead of environment-based configuration: + +```python +def create_client_direct(): + settings = ConnectionSettings( + environment_id="", # Not needed with DirectConnect URL + agent_identifier="", # Not needed with DirectConnect URL + direct_connect_url="https://api.powerplatform.com/copilotstudio/dataverse-backed/authenticated/bots/your-bot-id" + ) + token = acquire_token(...) + copilot_client = CopilotClient(settings, token) + return copilot_client +``` + +#### Advanced Configuration Options + +```python +settings = ConnectionSettings( + environment_id="your-env-id", + agent_identifier="your-agent-id", + cloud=PowerPlatformCloud.PROD, + copilot_agent_type=AgentType.PUBLISHED, + custom_power_platform_cloud=None, + direct_connect_url=None, # Optional: Direct URL to agent + use_experimental_endpoint=False, # Optional: Enable experimental features + enable_diagnostics=False, # Optional: Enable diagnostic logging + client_session_settings={"timeout": aiohttp.ClientTimeout(total=60)} # Optional: aiohttp settings +) +``` + ### Start a Conversation -The code below is summarized from the [main.py in the Copilot Studio Client](https://github.com/microsoft/Agents/blob/main/samples/python/copilotstudio-client/src/main.py). See that sample for complete & working code. + +#### Simple Start + +The code below is summarized from the [main.py in the Copilot Studio Client](https://github.com/microsoft/Agents/blob/main/samples/python/copilotstudio-client/src/main.py). See that sample for complete & working code. ```python - copilot_client = create_client() - act = copilot_client.start_conversation(True) +copilot_client = create_client() +async for activity in copilot_client.start_conversation(emit_start_conversation_event=True): + if activity.type == ActivityTypes.message: + print(f"\n{activity.text}") + +# Ask questions +async for reply in copilot_client.ask_question("Who are you?", conversation_id): + if reply.type == ActivityTypes.message: + print(f"\n{reply.text}") +``` - ... +#### Start with Advanced Options (Locale Support) - replies = copilot_client.ask_question("Who are you?", conversation_id) - async for reply in replies: - if reply.type == ActivityTypes.message: - print(f"\n{reply.text}") +```python +from microsoft_agents.copilotstudio.client import StartRequest + +# Create a start request with locale +start_request = StartRequest( + emit_start_conversation_event=True, + locale="en-US", # Optional: specify conversation locale + conversation_id="custom-conv-id" # Optional: provide your own conversation ID +) + +async for activity in copilot_client.start_conversation_with_request(start_request): + if activity.type == ActivityTypes.message: + print(f"\n{activity.text}") +``` + +### Send Activities + +#### Send a Custom Activity + +```python +from microsoft_agents.activity import Activity + +activity = Activity( + type="message", + text="Hello, agent!", + conversation={"id": conversation_id} +) + +async for reply in copilot_client.send_activity(activity): + print(f"Response: {reply.text}") +``` + +#### Execute with Explicit Conversation ID + +```python +# Execute an activity with a specific conversation ID +activity = Activity(type="message", text="What's the weather?") + +async for reply in copilot_client.execute(conversation_id="conv-123", activity=activity): + print(f"Response: {reply.text}") +``` + +### Subscribe to Conversation Events + +For real-time event streaming with resumption support: + +```python +from microsoft_agents.copilotstudio.client import SubscribeEvent + +# Subscribe to conversation events +async for subscribe_event in copilot_client.subscribe( + conversation_id="conv-123", + last_received_event_id=None # Optional: resume from last event +): + activity = subscribe_event.activity + event_id = subscribe_event.event_id # Use for resumption + + if activity.type == ActivityTypes.message: + print(f"[{event_id}] {activity.text}") ``` ### Environment Variables -Set up your `.env` file: + +Set up your `.env` file with the following options: + +#### Standard Environment-Based Configuration ```bash -# Required +# Required (unless using DIRECT_CONNECT_URL) ENVIRONMENT_ID=your-power-platform-environment-id AGENT_IDENTIFIER=your-copilot-studio-agent-id APP_CLIENT_ID=your-azure-app-client-id TENANT_ID=your-azure-tenant-id +# Optional Cloud Configuration +CLOUD=PROD # Options: PROD, GOV, HIGH, DOD, MOONCAKE, DEV, TEST, etc. +COPILOT_AGENT_TYPE=PUBLISHED # Options: PUBLISHED, PREBUILT +CUSTOM_POWER_PLATFORM_CLOUD=https://custom.cloud.com +``` + +#### DirectConnect URL Configuration (Alternative) + +```bash +# Required for DirectConnect mode +DIRECT_CONNECT_URL=https://api.powerplatform.com/copilotstudio/dataverse-backed/authenticated/bots/your-bot-id +APP_CLIENT_ID=your-azure-app-client-id +TENANT_ID=your-azure-tenant-id + # Optional -CLOUD=PROD -COPILOT_AGENT_TYPE=PUBLISHED -CUSTOM_POWER_PLATFORM_CLOUD=your-custom-cloud.com +CLOUD=PROD # Used for token audience resolution +``` + +#### Advanced Options + +```bash +# Experimental and diagnostic features +USE_EXPERIMENTAL_ENDPOINT=false # Enable experimental API endpoints +ENABLE_DIAGNOSTICS=false # Enable diagnostic logging +``` + +#### Using Environment Variables in Code + +The `ConnectionSettings.populate_from_environment()` helper method automatically loads these variables: + +```python +from microsoft_agents.copilotstudio.client import ConnectionSettings + +# Automatically loads from environment variables +settings_dict = ConnectionSettings.populate_from_environment() +settings = ConnectionSettings(**settings_dict) ``` ## Features -✅ **Real-time streaming** - Server-sent events for live responses -✅ **Multi-cloud support** - Works across all Power Platform clouds -✅ **Rich content** - Support for cards, actions, and attachments -✅ **Conversation management** - Maintain context across interactions -✅ **Custom activities** - Send structured data to agents +### Core Capabilities + +✅ **Real-time streaming** - Server-sent events for live responses +✅ **Multi-cloud support** - Works across all Power Platform clouds (PROD, GOV, HIGH, DOD, MOONCAKE, etc.) +✅ **Rich content** - Support for cards, actions, and attachments +✅ **Conversation management** - Maintain context across interactions +✅ **Custom activities** - Send structured data to agents ✅ **Async/await** - Modern Python async support +### Advanced Features + +✅ **DirectConnect URLs** - Simplified connection with direct bot URLs +✅ **Locale support** - Specify conversation language with `StartRequest` +✅ **Event subscription** - Subscribe to conversation events with SSE resumption +✅ **Multiple connection modes** - Environment-based or DirectConnect URL +✅ **Token audience resolution** - Automatic cloud detection from URLs +✅ **User-Agent tracking** - Automatic SDK version and platform headers +✅ **Environment configuration** - Automatic loading from environment variables +✅ **Experimental endpoints** - Toggle experimental API features +✅ **Diagnostic logging** - Enable detailed diagnostic information + +### API Methods + +| Method | Description | +|--------|-------------| +| `start_conversation()` | Start a new conversation with basic options | +| `start_conversation_with_request()` | Start with advanced options (locale, custom conversation ID) | +| `ask_question()` | Send a text question to the agent | +| `ask_question_with_activity()` | Send a custom Activity object | +| `send_activity()` | Send any activity (alias for ask_question_with_activity) | +| `execute()` | Execute an activity with explicit conversation ID | +| `subscribe()` | Subscribe to conversation events with resumption support | + +### Configuration Models + +| Class | Description | +|-------|-------------| +| `ConnectionSettings` | Main configuration class with all connection options | +| `StartRequest` | Advanced start options (locale, conversation ID) | +| `SubscribeEvent` | Event wrapper with activity and SSE event ID | +| `PowerPlatformCloud` | Enum for cloud environments | +| `AgentType` | Enum for agent types (PUBLISHED, PREBUILT) | +| `UserAgentHelper` | Utility for generating user-agent headers | + +## Connection Modes + +The client supports two connection modes: + +### 1. Environment-Based Connection (Standard) + +Uses environment ID and agent identifier to construct the connection URL: + +```python +settings = ConnectionSettings( + environment_id="aaaabbbb-1111-2222-3333-ccccddddeeee", + agent_identifier="cr123_myagent" +) +``` + +**URL Pattern:** +`https://{env-prefix}.{env-suffix}.environment.api.powerplatform.com/copilotstudio/dataverse-backed/authenticated/bots/{agent-id}/conversations` + +### 2. DirectConnect URL Mode (Simplified) + +Uses a direct URL to the agent, bypassing environment resolution: + +```python +settings = ConnectionSettings( + environment_id="", + agent_identifier="", + direct_connect_url="https://api.powerplatform.com/copilotstudio/dataverse-backed/authenticated/bots/cr123_myagent" +) +``` + +**Benefits:** +- Simpler configuration with single URL +- Automatic cloud detection for token audience +- Works across environments without environment ID lookup +- Useful for multi-tenant scenarios + +## Token Audience Resolution + +The client automatically determines the correct token audience: + +```python +# For environment-based connections +audience = PowerPlatformEnvironment.get_token_audience(settings) +# Returns: https://api.powerplatform.com/.default + +# For DirectConnect URLs +audience = PowerPlatformEnvironment.get_token_audience( + settings=ConnectionSettings("", "", direct_connect_url="https://api.gov.powerplatform.microsoft.us/...") +) +# Returns: https://api.gov.powerplatform.microsoft.us/.default +``` + ## Troubleshooting ### Common Issues **Authentication failed** - Verify your app is registered in Azure AD -- Check that token has `https://api.powerplatform.com/.default` scope +- Check that token has the correct audience scope (use `PowerPlatformEnvironment.get_token_audience()`) - Ensure your app has permissions to the Power Platform environment +- For DirectConnect URLs, verify cloud setting matches the URL domain **Agent not found** - Verify the environment ID and agent identifier - Check that the agent is published and accessible - Confirm you're using the correct cloud setting +- For DirectConnect URLs, ensure the URL is correct and complete **Connection timeout** - Check network connectivity to Power Platform - Verify firewall settings allow HTTPS traffic - Try a different cloud region if available +- Check if `client_session_settings` timeout is appropriate + +**Invalid DirectConnect URL** +- Ensure URL includes scheme (https://) +- Verify URL format matches expected pattern +- Check for trailing slashes (automatically normalized) +- Confirm URL points to the correct cloud environment ## Requirements diff --git a/test_samples/copilot_studio_client_sample/chat_console_service.py b/test_samples/copilot_studio_client_sample/chat_console_service.py index 5e466ec3..6921bdfd 100644 --- a/test_samples/copilot_studio_client_sample/chat_console_service.py +++ b/test_samples/copilot_studio_client_sample/chat_console_service.py @@ -1,22 +1,40 @@ -from microsoft_agents.copilotstudio.client import CopilotClient +from microsoft_agents.copilotstudio.client import CopilotClient, StartRequest from microsoft_agents.activity import Activity, ActivityTypes class ChatConsoleService: - def __init__(self, copilot_client: CopilotClient): + def __init__(self, copilot_client: CopilotClient, use_start_request: bool = False): self._copilot_client = copilot_client + self._use_start_request = use_start_request async def start_service(self): print("agent> ") # Attempt to connect to the copilot studio hosted agent here # if successful, this will loop though all events that the Copilot Studio agent sends to the client setup the conversation. - async for activity in self._copilot_client.start_conversation(): - if not activity: - raise Exception("ChatConsoleService.start_service: Activity is None") - - self._print_activity(activity) + if self._use_start_request: + # Use the new StartRequest model with optional locale + start_request = StartRequest( + emit_start_conversation_event=True, + locale="en-US", # Optional: specify locale + ) + async for activity in self._copilot_client.start_conversation_with_request( + start_request + ): + if not activity: + raise Exception( + "ChatConsoleService.start_service: Activity is None" + ) + self._print_activity(activity) + else: + # Use the simple start_conversation method + async for activity in self._copilot_client.start_conversation(): + if not activity: + raise Exception( + "ChatConsoleService.start_service: Activity is None" + ) + self._print_activity(activity) # Once we are connected and have initiated the conversation, begin the message loop with the Console. while True: diff --git a/test_samples/copilot_studio_client_sample/config.py b/test_samples/copilot_studio_client_sample/config.py index 6814ef1f..a2da0dc6 100644 --- a/test_samples/copilot_studio_client_sample/config.py +++ b/test_samples/copilot_studio_client_sample/config.py @@ -18,6 +18,9 @@ def __init__( cloud: Optional[PowerPlatformCloud] = None, copilot_agent_type: Optional[AgentType] = None, custom_power_platform_cloud: Optional[str] = None, + direct_connect_url: Optional[str] = None, + use_experimental_endpoint: Optional[bool] = None, + enable_diagnostics: Optional[bool] = None, ) -> None: self.app_client_id = app_client_id or environ.get("APP_CLIENT_ID") self.tenant_id = tenant_id or environ.get("TENANT_ID") @@ -27,21 +30,17 @@ def __init__( if not self.tenant_id: raise ValueError("Tenant ID must be provided") - environment_id = environment_id or environ.get("ENVIRONMENT_ID") - agent_identifier = agent_identifier or environ.get("AGENT_IDENTIFIER") - cloud = cloud or PowerPlatformCloud[environ.get("CLOUD", "UNKNOWN")] - copilot_agent_type = ( - copilot_agent_type - or AgentType[environ.get("COPILOT_agent_type", "PUBLISHED")] - ) - custom_power_platform_cloud = custom_power_platform_cloud or environ.get( - "CUSTOM_POWER_PLATFORM_CLOUD", None + # Use the parent class's method to populate settings from environment + settings = ConnectionSettings.populate_from_environment( + environment_id=environment_id, + agent_identifier=agent_identifier, + cloud=cloud, + copilot_agent_type=copilot_agent_type, + custom_power_platform_cloud=custom_power_platform_cloud, + direct_connect_url=direct_connect_url, + use_experimental_endpoint=use_experimental_endpoint, + enable_diagnostics=enable_diagnostics, ) - super().__init__( - environment_id, - agent_identifier, - cloud, - copilot_agent_type, - custom_power_platform_cloud, - ) + # Initialize the parent class with the populated settings + super().__init__(**settings) diff --git a/tests/copilotstudio_client/test_copilot_client.py b/tests/copilotstudio_client/test_copilot_client.py index ef3ecc30..d1bfa9bb 100644 --- a/tests/copilotstudio_client/test_copilot_client.py +++ b/tests/copilotstudio_client/test_copilot_client.py @@ -8,6 +8,9 @@ ConnectionSettings, CopilotClient, PowerPlatformEnvironment, + StartRequest, + SubscribeEvent, + UserAgentHelper, ) from aiohttp import ClientSession, ClientError @@ -89,3 +92,409 @@ async def content(): assert message.conversation.id == "1234567890" assert count == 1 + + +@pytest.mark.asyncio +async def test_copilot_client_start_with_request(mocker): + # Define the connection settings + connection_settings = ConnectionSettings( + "environment-id", + "agent-id", + client_session_settings={"base_url": "https://api.copilotstudio.com"}, + ) + + mock_session = mocker.MagicMock(spec=ClientSession) + mock_session.__aenter__.return_value = mock_session + + @asynccontextmanager + async def response(): + mock_response = mocker.Mock() + mock_response.status = 200 + + activity = Activity( + type="message", text="Hello from start request!", conversation={"id": "123"} + ) + activity_json = activity.model_dump_json(exclude_unset=True) + + async def content(): + yield "event: activity".encode() + yield f"data: {activity_json}".encode() + + mock_response.content = content() + + yield mock_response + + mock_session.post.return_value = response() + + mocker.patch("aiohttp.ClientSession", return_value=mock_session) + + # Create a CopilotClient instance + copilot_client = CopilotClient(connection_settings, "token") + + # Create a start request + start_request = StartRequest( + emit_start_conversation_event=True, locale="en-US", conversation_id="123" + ) + + count = 0 + async for message in copilot_client.start_conversation_with_request(start_request): + count += 1 + assert message.type == "message" + assert message.text == "Hello from start request!" + assert message.conversation.id == "123" + + assert count == 1 + + +@pytest.mark.asyncio +async def test_copilot_client_send_activity(mocker): + # Define the connection settings + connection_settings = ConnectionSettings( + "environment-id", + "agent-id", + client_session_settings={"base_url": "https://api.copilotstudio.com"}, + ) + + mock_session = mocker.MagicMock(spec=ClientSession) + mock_session.__aenter__.return_value = mock_session + + @asynccontextmanager + async def response(): + mock_response = mocker.Mock() + mock_response.status = 200 + + activity = Activity( + type="message", text="Response to activity", conversation={"id": "456"} + ) + activity_json = activity.model_dump_json(exclude_unset=True) + + async def content(): + yield "event: activity".encode() + yield f"data: {activity_json}".encode() + + mock_response.content = content() + + yield mock_response + + mock_session.post.return_value = response() + + mocker.patch("aiohttp.ClientSession", return_value=mock_session) + + # Create a CopilotClient instance + copilot_client = CopilotClient(connection_settings, "token") + + # Create an activity to send + activity = Activity(type="message", text="Test message", conversation={"id": "456"}) + + count = 0 + async for message in copilot_client.send_activity(activity): + count += 1 + assert message.type == "message" + assert message.text == "Response to activity" + + assert count == 1 + + +@pytest.mark.asyncio +async def test_copilot_client_execute(mocker): + # Define the connection settings + connection_settings = ConnectionSettings( + "environment-id", + "agent-id", + client_session_settings={"base_url": "https://api.copilotstudio.com"}, + ) + + mock_session = mocker.MagicMock(spec=ClientSession) + mock_session.__aenter__.return_value = mock_session + + @asynccontextmanager + async def response(): + mock_response = mocker.Mock() + mock_response.status = 200 + + activity = Activity( + type="message", text="Execute response", conversation={"id": "789"} + ) + activity_json = activity.model_dump_json(exclude_unset=True) + + async def content(): + yield "event: activity".encode() + yield f"data: {activity_json}".encode() + + mock_response.content = content() + + yield mock_response + + mock_session.post.return_value = response() + + mocker.patch("aiohttp.ClientSession", return_value=mock_session) + + # Create a CopilotClient instance + copilot_client = CopilotClient(connection_settings, "token") + + # Create an activity to execute + activity = Activity(type="message", text="Execute message") + + count = 0 + async for message in copilot_client.execute("789", activity): + count += 1 + assert message.type == "message" + assert message.text == "Execute response" + assert message.conversation.id == "789" + + assert count == 1 + + +@pytest.mark.asyncio +async def test_copilot_client_subscribe(mocker): + # Define the connection settings + connection_settings = ConnectionSettings( + "environment-id", + "agent-id", + client_session_settings={"base_url": "https://api.copilotstudio.com"}, + ) + + mock_session = mocker.MagicMock(spec=ClientSession) + mock_session.__aenter__.return_value = mock_session + + @asynccontextmanager + async def response(): + mock_response = mocker.Mock() + mock_response.status = 200 + + activity = Activity( + type="message", text="Subscribe event", conversation={"id": "999"} + ) + activity_json = activity.model_dump_json(exclude_unset=True) + + async def content(): + yield "id: event-123".encode() + yield "event: activity".encode() + yield f"data: {activity_json}".encode() + + mock_response.content = content() + + yield mock_response + + mock_session.get.return_value = response() + + mocker.patch("aiohttp.ClientSession", return_value=mock_session) + + # Create a CopilotClient instance + copilot_client = CopilotClient(connection_settings, "token") + + count = 0 + async for subscribe_event in copilot_client.subscribe("999"): + count += 1 + assert isinstance(subscribe_event, SubscribeEvent) + assert subscribe_event.activity.type == "message" + assert subscribe_event.activity.text == "Subscribe event" + assert subscribe_event.event_id == "event-123" + + assert count == 1 + + +def test_user_agent_helper(): + user_agent = UserAgentHelper.get_user_agent_header() + assert "CopilotStudioClient.agents-sdk-python" in user_agent + assert "Python/" in user_agent + + +def test_start_request(): + start_request = StartRequest( + emit_start_conversation_event=True, locale="en-US", conversation_id="test-123" + ) + assert start_request.emit_start_conversation_event is True + assert start_request.locale == "en-US" + assert start_request.conversation_id == "test-123" + + +def test_connection_settings_with_new_properties(): + connection_settings = ConnectionSettings( + "environment-id", + "agent-id", + direct_connect_url="https://custom.url", + use_experimental_endpoint=True, + enable_diagnostics=True, + ) + assert connection_settings.direct_connect_url == "https://custom.url" + assert connection_settings.use_experimental_endpoint is True + assert connection_settings.enable_diagnostics is True + + +def test_connection_settings_direct_url_only(): + # Should allow creation with only direct_connect_url + connection_settings = ConnectionSettings( + "", "", direct_connect_url="https://custom.url" + ) + assert connection_settings.direct_connect_url == "https://custom.url" + + +def test_scope_from_settings(): + connection_settings = ConnectionSettings("env-id", "agent-id") + scope = CopilotClient.scope_from_settings(connection_settings) + assert scope.endswith("/.default") + assert "https://" in scope + + +def test_populate_from_environment(monkeypatch): + from microsoft_agents.copilotstudio.client import PowerPlatformCloud, AgentType + + # Set up environment variables + monkeypatch.setenv("ENVIRONMENT_ID", "test-env-id") + monkeypatch.setenv("AGENT_IDENTIFIER", "test-agent-id") + monkeypatch.setenv("CLOUD", "PROD") + monkeypatch.setenv("COPILOT_AGENT_TYPE", "PUBLISHED") + monkeypatch.setenv("CUSTOM_POWER_PLATFORM_CLOUD", "https://custom.cloud") + monkeypatch.setenv("DIRECT_CONNECT_URL", "https://direct.url") + monkeypatch.setenv("USE_EXPERIMENTAL_ENDPOINT", "true") + monkeypatch.setenv("ENABLE_DIAGNOSTICS", "true") + + # Call the populate method + settings_dict = ConnectionSettings.populate_from_environment() + + # Verify the returned dictionary + assert settings_dict["environment_id"] == "test-env-id" + assert settings_dict["agent_identifier"] == "test-agent-id" + assert settings_dict["cloud"] == PowerPlatformCloud.PROD + assert settings_dict["copilot_agent_type"] == AgentType.PUBLISHED + assert settings_dict["custom_power_platform_cloud"] == "https://custom.cloud" + assert settings_dict["direct_connect_url"] == "https://direct.url" + assert settings_dict["use_experimental_endpoint"] is True + assert settings_dict["enable_diagnostics"] is True + + +def test_populate_from_environment_with_overrides(): + # Call with explicit overrides (should not use env vars) + settings_dict = ConnectionSettings.populate_from_environment( + environment_id="override-env", + agent_identifier="override-agent", + use_experimental_endpoint=False, + enable_diagnostics=False, + ) + + # Verify the overrides were used + assert settings_dict["environment_id"] == "override-env" + assert settings_dict["agent_identifier"] == "override-agent" + assert settings_dict["use_experimental_endpoint"] is False + assert settings_dict["enable_diagnostics"] is False + + +def test_power_platform_environment_with_direct_connect_url(): + # Test DirectConnect URL mode + connection_settings = ConnectionSettings( + "", + "", + direct_connect_url="https://api.powerplatform.com/copilotstudio/dataverse-backed/authenticated/bots/test-bot", + ) + + url = PowerPlatformEnvironment.get_copilot_studio_connection_url( + settings=connection_settings + ) + + assert "https://api.powerplatform.com" in url + assert "/conversations" in url + assert "api-version=" in url + + +def test_power_platform_environment_direct_connect_with_conversation_id(): + # Test DirectConnect URL with conversation ID + connection_settings = ConnectionSettings( + "", + "", + direct_connect_url="https://api.powerplatform.com/copilotstudio/dataverse-backed/authenticated/bots/test-bot", + ) + + url = PowerPlatformEnvironment.get_copilot_studio_connection_url( + settings=connection_settings, conversation_id="conv-123" + ) + + assert "https://api.powerplatform.com" in url + assert "/conversations/conv-123" in url + assert "api-version=" in url + + +def test_power_platform_environment_create_subscribe_link(): + from microsoft_agents.copilotstudio.client import PowerPlatformCloud + + # Test subscribe link creation + connection_settings = ConnectionSettings( + "test-env", "test-agent", cloud=PowerPlatformCloud.PROD + ) + + url = PowerPlatformEnvironment.get_copilot_studio_connection_url( + settings=connection_settings, + conversation_id="conv-456", + create_subscribe_link=True, + ) + + assert "/conversations/conv-456/subscribe" in url + + +def test_power_platform_environment_direct_connect_subscribe_link(): + # Test DirectConnect URL with subscribe link + connection_settings = ConnectionSettings( + "", + "", + direct_connect_url="https://api.powerplatform.com/copilotstudio/dataverse-backed/authenticated/bots/test-bot", + ) + + url = PowerPlatformEnvironment.get_copilot_studio_connection_url( + settings=connection_settings, + conversation_id="conv-789", + create_subscribe_link=True, + ) + + assert "/conversations/conv-789/subscribe" in url + + +def test_decode_cloud_from_uri(): + from microsoft_agents.copilotstudio.client import PowerPlatformCloud + + # Test cloud decoding from various URIs + prod_url = "https://api.powerplatform.com/some/path" + gov_url = "https://api.gov.powerplatform.microsoft.us/some/path" + unknown_url = "https://custom.domain.com/some/path" + + assert ( + PowerPlatformEnvironment._decode_cloud_from_uri(prod_url) + == PowerPlatformCloud.PROD + ) + assert ( + PowerPlatformEnvironment._decode_cloud_from_uri(gov_url) + == PowerPlatformCloud.GOV_FR + ) + assert ( + PowerPlatformEnvironment._decode_cloud_from_uri(unknown_url) + == PowerPlatformCloud.UNKNOWN + ) + + +def test_get_token_audience_with_direct_connect(): + # Test token audience with DirectConnect URL + connection_settings = ConnectionSettings( + "", + "", + direct_connect_url="https://api.powerplatform.com/copilotstudio/dataverse-backed/authenticated/bots/test-bot", + ) + + audience = PowerPlatformEnvironment.get_token_audience(settings=connection_settings) + + assert audience == "https://api.powerplatform.com/.default" + + +def test_direct_connect_url_path_normalization(): + # Test that paths with /conversations are properly normalized + connection_settings = ConnectionSettings( + "", + "", + direct_connect_url="https://api.powerplatform.com/copilotstudio/dataverse-backed/authenticated/bots/test-bot/conversations", + ) + + url = PowerPlatformEnvironment.get_copilot_studio_connection_url( + settings=connection_settings, conversation_id="conv-abc" + ) + + # Should not have double /conversations/conversations + assert "/conversations/conversations" not in url + assert "/conversations/conv-abc" in url From 7a97ff06b706de24343d9b245d624c4337e46b4e Mon Sep 17 00:00:00 2001 From: MattB Date: Sat, 14 Feb 2026 11:54:15 -0800 Subject: [PATCH 02/10] feat: Add diagnostic logging and experimental endpoint capture to CopilotClient --- .../copilotstudio/client/copilot_client.py | 48 +++- .../readme.md | 40 ++- .../test_copilot_client.py | 233 ++++++++++++++++++ 3 files changed, 314 insertions(+), 7 deletions(-) diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py index 127fb49f..28a5d791 100644 --- a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. import aiohttp +import logging from typing import AsyncIterable, Callable, Optional from microsoft_agents.activity import Activity, ActivityTypes, ConversationAccount @@ -19,6 +20,7 @@ class CopilotClient: EVENT_STREAM_TYPE = "text/event-stream" APPLICATION_JSON_TYPE = "application/json" + EXPERIMENTAL_URL_HEADER_KEY = "x-ms-d2e-experimental" _current_conversation_id = "" @@ -29,9 +31,9 @@ def __init__( ): self.settings = settings self._token = token - # TODO: Add logger - # self.logger = logger + self._logger = logging.getLogger(__name__) self.conversation_id = "" + self._island_experimental_url = "" async def post_request( self, url: str, data: dict, headers: dict @@ -46,17 +48,43 @@ async def post_request( # Add User-Agent header headers["User-Agent"] = UserAgentHelper.get_user_agent_header() + # Log diagnostic information if enabled + if self.settings.enable_diagnostics: + self._logger.debug(f">>> SEND TO {url}") + async with aiohttp.ClientSession( **self.settings.client_session_settings ) as session: async with session.post(url, json=data, headers=headers) as response: if response.status != 200: - # self.logger(f"Error sending request: {response.status}") + self._logger.error(f"Error sending request: {response.status}") raise aiohttp.ClientError( f"Error sending request: {response.status}" ) + # Log response headers if diagnostics enabled + if self.settings.enable_diagnostics: + self._logger.debug("=" * 53) + for header_key, header_value in response.headers.items(): + self._logger.debug(f"{header_key} = {header_value}") + self._logger.debug("=" * 53) + + # Capture experimental endpoint if enabled and not already using DirectConnect + experimental_url = response.headers.get( + self.EXPERIMENTAL_URL_HEADER_KEY + ) + if experimental_url: + if ( + self.settings.use_experimental_endpoint + and not self.settings.direct_connect_url + ): + self._island_experimental_url = experimental_url + self.settings.direct_connect_url = self._island_experimental_url + self._logger.debug( + f"Island Experimental URL: {self._island_experimental_url}" + ) + # Set conversation ID from response header when status is 200 conversation_id_header = response.headers.get("x-ms-conversationid") if conversation_id_header: @@ -242,16 +270,30 @@ async def subscribe( # Add User-Agent header headers["User-Agent"] = UserAgentHelper.get_user_agent_header() + # Log diagnostic information if enabled + if self.settings.enable_diagnostics: + self._logger.debug(f">>> SEND TO {url}") + async with aiohttp.ClientSession( **self.settings.client_session_settings ) as session: async with session.get(url, headers=headers) as response: if response.status != 200: + self._logger.error( + f"Error subscribing to conversation: {response.status}" + ) raise aiohttp.ClientError( f"Error subscribing to conversation: {response.status}" ) + # Log response headers if diagnostics enabled + if self.settings.enable_diagnostics: + self._logger.debug("=" * 53) + for header_key, header_value in response.headers.items(): + self._logger.debug(f"{header_key} = {header_value}") + self._logger.debug("=" * 53) + event_id = None event_type = None async for line in response.content: diff --git a/libraries/microsoft-agents-copilotstudio-client/readme.md b/libraries/microsoft-agents-copilotstudio-client/readme.md index 68cc8528..bd1dbf4a 100644 --- a/libraries/microsoft-agents-copilotstudio-client/readme.md +++ b/libraries/microsoft-agents-copilotstudio-client/readme.md @@ -133,11 +133,30 @@ settings = ConnectionSettings( custom_power_platform_cloud=None, direct_connect_url=None, # Optional: Direct URL to agent use_experimental_endpoint=False, # Optional: Enable experimental features - enable_diagnostics=False, # Optional: Enable diagnostic logging + enable_diagnostics=False, # Optional: Enable diagnostic logging (logs HTTP details) client_session_settings={"timeout": aiohttp.ClientTimeout(total=60)} # Optional: aiohttp settings ) ``` +**Diagnostic Logging Details**: +When `enable_diagnostics=True`, the CopilotClient logs detailed HTTP communication using Python's `logging` module at the `DEBUG` level: +- Pre-request: Logs the full request URL (`>>> SEND TO {url}`) +- Post-response: Logs all HTTP response headers in a formatted table +- Errors: Logs error messages with status codes + +To see diagnostic output, configure your Python logging: +```python +import logging +logging.basicConfig(level=logging.DEBUG) +``` + +**Experimental Endpoint Details**: +When `use_experimental_endpoint=True`, the CopilotClient will automatically capture and use the experimental endpoint URL from the first response: +- The server returns the experimental endpoint in the `x-ms-d2e-experimental` response header +- Once captured, this URL is stored in `settings.direct_connect_url` and used for all subsequent requests +- This feature is only active when `use_experimental_endpoint=True` AND `direct_connect_url` is not already set +- The experimental endpoint allows access to pre-release features and optimizations + ### Start a Conversation #### Simple Start @@ -254,10 +273,23 @@ CLOUD=PROD # Used for token audience resolution ```bash # Experimental and diagnostic features -USE_EXPERIMENTAL_ENDPOINT=false # Enable experimental API endpoints -ENABLE_DIAGNOSTICS=false # Enable diagnostic logging +USE_EXPERIMENTAL_ENDPOINT=false # Enable automatic experimental endpoint capture +ENABLE_DIAGNOSTICS=false # Enable diagnostic logging (logs HTTP requests/responses) ``` +**Experimental Endpoint**: When `USE_EXPERIMENTAL_ENDPOINT=true`, the client automatically captures and uses the experimental endpoint URL from the server's `x-ms-d2e-experimental` response header. This feature: +- Only activates when `direct_connect_url` is not already set +- Captures the URL from the first response and stores it for all subsequent requests +- Provides access to pre-release features and performance optimizations +- Useful for testing new capabilities before general availability + +**Diagnostic Logging**: When `ENABLE_DIAGNOSTICS=true` or `enable_diagnostics=True`, the client will log detailed HTTP request and response information including: +- Request URLs before sending +- All response headers with their values +- Error messages for failed requests + +This is useful for debugging connection issues, authentication problems, or understanding the communication flow with Copilot Studio. Diagnostic logs use Python's standard `logging` module at the `DEBUG` level. + #### Using Environment Variables in Code The `ConnectionSettings.populate_from_environment()` helper method automatically loads these variables: @@ -291,7 +323,7 @@ settings = ConnectionSettings(**settings_dict) ✅ **User-Agent tracking** - Automatic SDK version and platform headers ✅ **Environment configuration** - Automatic loading from environment variables ✅ **Experimental endpoints** - Toggle experimental API features -✅ **Diagnostic logging** - Enable detailed diagnostic information +✅ **Diagnostic logging** - HTTP request/response logging for debugging and troubleshooting ### API Methods diff --git a/tests/copilotstudio_client/test_copilot_client.py b/tests/copilotstudio_client/test_copilot_client.py index d1bfa9bb..fdb61aa1 100644 --- a/tests/copilotstudio_client/test_copilot_client.py +++ b/tests/copilotstudio_client/test_copilot_client.py @@ -498,3 +498,236 @@ def test_direct_connect_url_path_normalization(): # Should not have double /conversations/conversations assert "/conversations/conversations" not in url assert "/conversations/conv-abc" in url + + +@pytest.mark.asyncio +async def test_enable_diagnostics_logging(mocker, caplog): + import logging + + # Define the connection settings with diagnostics enabled + connection_settings = ConnectionSettings( + "environment-id", + "agent-id", + client_session_settings={"base_url": "https://api.copilotstudio.com"}, + enable_diagnostics=True, + ) + + mock_session = mocker.MagicMock(spec=ClientSession) + mock_session.__aenter__.return_value = mock_session + + @asynccontextmanager + async def response(): + mock_response = mocker.Mock() + mock_response.status = 200 + mock_response.headers = { + "Content-Type": "text/event-stream", + "x-ms-conversationid": "test-conv-123", + } + + activity = Activity( + type="message", text="Test response", conversation={"id": "test-conv-123"} + ) + activity_json = activity.model_dump_json(exclude_unset=True) + + async def content(): + yield "event: activity".encode() + yield f"data: {activity_json}".encode() + + mock_response.content = content() + + yield mock_response + + mock_session.post.return_value = response() + + mocker.patch("aiohttp.ClientSession", return_value=mock_session) + + # Create a CopilotClient instance + copilot_client = CopilotClient(connection_settings, "token") + + # Enable logging capture at DEBUG level + with caplog.at_level(logging.DEBUG): + count = 0 + async for message in copilot_client.start_conversation(): + count += 1 + + assert count == 1 + + # Check that diagnostic messages were logged + debug_messages = [record.message for record in caplog.records] + assert any(">>> SEND TO" in msg for msg in debug_messages) + assert any("Content-Type" in msg for msg in debug_messages) + assert any("=" * 53 in msg for msg in debug_messages) + + +@pytest.mark.asyncio +async def test_experimental_endpoint_capture(mocker): + # Define the connection settings with experimental endpoint enabled + connection_settings = ConnectionSettings( + "environment-id", + "agent-id", + client_session_settings={"base_url": "https://api.copilotstudio.com"}, + use_experimental_endpoint=True, + ) + + # Verify initial state + assert connection_settings.direct_connect_url is None + assert connection_settings.use_experimental_endpoint is True + + mock_session = mocker.MagicMock(spec=ClientSession) + mock_session.__aenter__.return_value = mock_session + + experimental_url = "https://experimental.api.powerplatform.com/bot/test-bot" + + @asynccontextmanager + async def response(): + mock_response = mocker.Mock() + mock_response.status = 200 + mock_response.headers = { + "Content-Type": "text/event-stream", + "x-ms-conversationid": "test-conv-123", + "x-ms-d2e-experimental": experimental_url, + } + + activity = Activity( + type="message", text="Test response", conversation={"id": "test-conv-123"} + ) + activity_json = activity.model_dump_json(exclude_unset=True) + + async def content(): + yield "event: activity".encode() + yield f"data: {activity_json}".encode() + + mock_response.content = content() + + yield mock_response + + mock_session.post.return_value = response() + + mocker.patch("aiohttp.ClientSession", return_value=mock_session) + + # Create a CopilotClient instance + copilot_client = CopilotClient(connection_settings, "token") + + count = 0 + async for message in copilot_client.start_conversation(): + count += 1 + + assert count == 1 + + # Verify that the experimental URL was captured and stored + assert copilot_client._island_experimental_url == experimental_url + assert copilot_client.settings.direct_connect_url == experimental_url + + +@pytest.mark.asyncio +async def test_experimental_endpoint_not_captured_when_disabled(mocker): + # Define the connection settings with experimental endpoint disabled + connection_settings = ConnectionSettings( + "environment-id", + "agent-id", + client_session_settings={"base_url": "https://api.copilotstudio.com"}, + use_experimental_endpoint=False, + ) + + mock_session = mocker.MagicMock(spec=ClientSession) + mock_session.__aenter__.return_value = mock_session + + experimental_url = "https://experimental.api.powerplatform.com/bot/test-bot" + + @asynccontextmanager + async def response(): + mock_response = mocker.Mock() + mock_response.status = 200 + mock_response.headers = { + "Content-Type": "text/event-stream", + "x-ms-conversationid": "test-conv-123", + "x-ms-d2e-experimental": experimental_url, + } + + activity = Activity( + type="message", text="Test response", conversation={"id": "test-conv-123"} + ) + activity_json = activity.model_dump_json(exclude_unset=True) + + async def content(): + yield "event: activity".encode() + yield f"data: {activity_json}".encode() + + mock_response.content = content() + + yield mock_response + + mock_session.post.return_value = response() + + mocker.patch("aiohttp.ClientSession", return_value=mock_session) + + # Create a CopilotClient instance + copilot_client = CopilotClient(connection_settings, "token") + + count = 0 + async for message in copilot_client.start_conversation(): + count += 1 + + assert count == 1 + + # Verify that the experimental URL was NOT captured + assert copilot_client._island_experimental_url == "" + assert copilot_client.settings.direct_connect_url is None + + +@pytest.mark.asyncio +async def test_experimental_endpoint_not_captured_when_direct_connect_set(mocker): + # Define the connection settings with both experimental endpoint and direct connect URL + direct_url = "https://direct.api.powerplatform.com/bot/direct-bot" + connection_settings = ConnectionSettings( + "environment-id", + "agent-id", + client_session_settings={"base_url": "https://api.copilotstudio.com"}, + use_experimental_endpoint=True, + direct_connect_url=direct_url, + ) + + mock_session = mocker.MagicMock(spec=ClientSession) + mock_session.__aenter__.return_value = mock_session + + experimental_url = "https://experimental.api.powerplatform.com/bot/test-bot" + + @asynccontextmanager + async def response(): + mock_response = mocker.Mock() + mock_response.status = 200 + mock_response.headers = { + "Content-Type": "text/event-stream", + "x-ms-conversationid": "test-conv-123", + "x-ms-d2e-experimental": experimental_url, + } + + activity = Activity( + type="message", text="Test response", conversation={"id": "test-conv-123"} + ) + activity_json = activity.model_dump_json(exclude_unset=True) + + async def content(): + yield "event: activity".encode() + yield f"data: {activity_json}".encode() + + mock_response.content = content() + + yield mock_response + + mock_session.post.return_value = response() + + mocker.patch("aiohttp.ClientSession", return_value=mock_session) + + # Create a CopilotClient instance + copilot_client = CopilotClient(connection_settings, "token") + + count = 0 + async for message in copilot_client.start_conversation(): + count += 1 + + assert count == 1 + + # Verify that the experimental URL was NOT captured (direct_connect_url takes precedence) + assert copilot_client._island_experimental_url == "" + assert copilot_client.settings.direct_connect_url == direct_url From 72451558e7224810224956002d488d7328f7b135 Mon Sep 17 00:00:00 2001 From: MattB Date: Sat, 14 Feb 2026 12:04:29 -0800 Subject: [PATCH 03/10] Potential fix for code scanning alert no. 19: Incomplete URL substring sanitization Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- tests/copilotstudio_client/test_copilot_client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/copilotstudio_client/test_copilot_client.py b/tests/copilotstudio_client/test_copilot_client.py index fdb61aa1..c343820e 100644 --- a/tests/copilotstudio_client/test_copilot_client.py +++ b/tests/copilotstudio_client/test_copilot_client.py @@ -14,7 +14,7 @@ ) from aiohttp import ClientSession, ClientError - +from urllib.parse import urlparse @pytest.mark.asyncio async def test_copilot_client_error(mocker): @@ -409,7 +409,9 @@ def test_power_platform_environment_direct_connect_with_conversation_id(): settings=connection_settings, conversation_id="conv-123" ) - assert "https://api.powerplatform.com" in url + parsed = urlparse(url) + assert parsed.scheme == "https" + assert parsed.hostname == "api.powerplatform.com" assert "/conversations/conv-123" in url assert "api-version=" in url From faef15a633bd3f742c460e89aea2eb418ac2523d Mon Sep 17 00:00:00 2001 From: MattB Date: Sat, 14 Feb 2026 12:07:08 -0800 Subject: [PATCH 04/10] Potential fix for code scanning alert no. 18: Incomplete URL substring sanitization Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- tests/copilotstudio_client/test_copilot_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/copilotstudio_client/test_copilot_client.py b/tests/copilotstudio_client/test_copilot_client.py index c343820e..e82e2869 100644 --- a/tests/copilotstudio_client/test_copilot_client.py +++ b/tests/copilotstudio_client/test_copilot_client.py @@ -392,7 +392,9 @@ def test_power_platform_environment_with_direct_connect_url(): settings=connection_settings ) - assert "https://api.powerplatform.com" in url + parsed_url = urlparse(url) + assert parsed_url.scheme == "https" + assert parsed_url.hostname == "api.powerplatform.com" assert "/conversations" in url assert "api-version=" in url From b9696ce241f7b7ef1a7dacefa8e04c4646c66694 Mon Sep 17 00:00:00 2001 From: MattB Date: Sat, 14 Feb 2026 12:12:02 -0800 Subject: [PATCH 05/10] Update libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/user_agent_helper.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../copilotstudio/client/user_agent_helper.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/user_agent_helper.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/user_agent_helper.py index b66d59a6..705a5148 100644 --- a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/user_agent_helper.py +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/user_agent_helper.py @@ -4,6 +4,11 @@ import sys import platform +try: + from importlib import metadata as importlib_metadata +except ImportError: # pragma: no cover - Python < 3.8 + importlib_metadata = None # type: ignore[assignment] + class UserAgentHelper: """ @@ -11,8 +16,17 @@ class UserAgentHelper: """ CLIENT_NAME = "CopilotStudioClient" - CLIENT_VERSION = "0.8.0" # Should match package version + # Derive client version from installed package metadata, with a safe fallback. + if importlib_metadata is not None: + try: + _dist_name = (__package__ or __name__).split(".")[0] + CLIENT_VERSION = importlib_metadata.version(_dist_name) + except Exception: + # Fallback to a static version if metadata lookup fails + CLIENT_VERSION = "0.8.0" + else: + CLIENT_VERSION = "0.8.0" @staticmethod def get_user_agent_header() -> str: """ From 3f84ac62dcaf5dbb7d25b68c145eb6489b56dd59 Mon Sep 17 00:00:00 2001 From: MattB Date: Sat, 14 Feb 2026 12:13:34 -0800 Subject: [PATCH 06/10] Update libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../copilotstudio/client/copilot_client.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py index 28a5d791..fb225fc5 100644 --- a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py @@ -249,14 +249,12 @@ async def subscribe( if not conversation_id: raise ValueError("CopilotClient.subscribe: conversation_id cannot be None") - # Build the subscribe URL + # Build the subscribe URL using the environment helper to ensure correct path and query handling url = PowerPlatformEnvironment.get_copilot_studio_connection_url( - settings=self.settings, conversation_id=conversation_id + settings=self.settings, + conversation_id=conversation_id, + create_subscribe_link=True, ) - - # Append /subscribe to the URL - url = url.replace("/conversations/", "/subscribe/") - headers = { "Content-Type": self.APPLICATION_JSON_TYPE, "Authorization": f"Bearer {self._token}", From 9ba9904abfa60a81e0e35c627c6dd3190f8794ac Mon Sep 17 00:00:00 2001 From: MattB Date: Sat, 14 Feb 2026 12:15:07 -0800 Subject: [PATCH 07/10] Update libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../copilotstudio/client/power_platform_environment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py index 057b0b4f..98c672ff 100644 --- a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py @@ -150,7 +150,7 @@ def get_token_audience( parsed_direct = urlparse(direct_url) if not (parsed_direct.scheme and parsed_direct.netloc): raise ValueError( - "DirectConnectUrl must be provided when DirectConnectUrl is set" + "Invalid DirectConnectUrl: an absolute URL with scheme and host is required" ) decoded_cloud = PowerPlatformEnvironment._decode_cloud_from_uri(direct_url) From 36f00ec03dcd730102e15873931e354a2e33f618 Mon Sep 17 00:00:00 2001 From: MattB Date: Sat, 14 Feb 2026 12:19:15 -0800 Subject: [PATCH 08/10] Update libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../copilotstudio/client/power_platform_environment.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py index 98c672ff..a25321b1 100644 --- a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/power_platform_environment.py @@ -143,6 +143,11 @@ def get_token_audience( str(copilot_studio_errors.CustomCloudOrBaseAddressRequired) ) + # Normalize custom cloud base address to host-only (strip any scheme) + if cloud == PowerPlatformCloud.OTHER and cloud_base_address: + parsed_base = urlparse(cloud_base_address) + if parsed_base.scheme and parsed_base.netloc: + cloud_base_address = parsed_base.netloc cloud_base_address = cloud_base_address or "api.unknown.powerplatform.com" return f"https://{PowerPlatformEnvironment.get_endpoint_suffix(cloud, cloud_base_address)}/.default" else: From 65876aafb186a5b0d469de88a13cbc333d29db61 Mon Sep 17 00:00:00 2001 From: MattB Date: Sat, 14 Feb 2026 12:38:33 -0800 Subject: [PATCH 09/10] Add newline for formatting consistency in user_agent_helper.py --- .../microsoft_agents/copilotstudio/client/user_agent_helper.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/user_agent_helper.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/user_agent_helper.py index 705a5148..efefbcc2 100644 --- a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/user_agent_helper.py +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/user_agent_helper.py @@ -27,6 +27,7 @@ class UserAgentHelper: CLIENT_VERSION = "0.8.0" else: CLIENT_VERSION = "0.8.0" + @staticmethod def get_user_agent_header() -> str: """ From 8b70fcfb7645e46a1c61af09c59b48c65faf7bc4 Mon Sep 17 00:00:00 2001 From: MattB Date: Sat, 14 Feb 2026 12:43:56 -0800 Subject: [PATCH 10/10] Update libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../microsoft_agents/copilotstudio/client/copilot_client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py index fb225fc5..967b0061 100644 --- a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/copilot_client.py @@ -303,6 +303,9 @@ async def subscribe( activity_data = line[5:].decode("utf-8").strip() activity = Activity.model_validate_json(activity_data) yield SubscribeEvent(activity=activity, event_id=event_id) + # Reset per-event state so IDs and types do not leak across events + event_id = None + event_type = None @staticmethod def scope_from_settings(settings: ConnectionSettings) -> str: