Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from dataclasses import dataclass
from typing import Literal, Optional
from urllib.parse import quote, urlsplit, urlunsplit

from .connection_settings import ConnectionSettings
from .errors import copilot_studio_errors
from .power_platform_environment import PowerPlatformEnvironment

ConversationOperation = Literal["createConversation", "executeTurn", "subscribe"]
_CONVERSATION_ID = "{conversationId}"


def get_discovery_url(settings: ConnectionSettings) -> str:
url = urlsplit(PowerPlatformEnvironment.get_copilot_studio_connection_url(settings))
return urlunsplit(
(
url.scheme,
url.netloc,
f"/copilotstudio/agents/{quote(settings.agent_identifier, safe='')}/conversation-endpoints",
"",
"",
)
)


def _validate_url(
value: object, discovery_url: str, requires_conversation_id: bool
) -> str:
error = str(copilot_studio_errors.InvalidDiscoveryOperationUrl)
if not isinstance(value, str) or not value.strip():
raise ValueError(error)

remainder = value.replace(_CONVERSATION_ID, "")
if (
requires_conversation_id != (_CONVERSATION_ID in value)
or "{" in remainder
or "}" in remainder
or "\\" in value
or any(character.isspace() or ord(character) < 32 for character in value)
):
raise ValueError(error)

try:
discovery = urlsplit(discovery_url)
url = urlsplit(value.replace(_CONVERSATION_ID, "conversation"))
host = discovery.hostname or ""
suffix_index = host.find(".environment.")
suffix = host[suffix_index:] if suffix_index >= 0 else "." + host
valid = (
url.scheme == "https"
and (url.port if url.port is not None else 443)
== (discovery.port if discovery.port is not None else 443)
and bool(url.hostname)
and url.hostname.endswith(suffix)
and url.username is None
and url.password is None
and not url.fragment
)
except ValueError as exc:
raise ValueError(error) from exc
if not valid:
raise ValueError(error)
return value


@dataclass(frozen=True)
class ConversationEndpoints:
create_conversation: str
execute_turn: str
continue_turn: str
subscribe: Optional[str]

@classmethod
def from_response(cls, data: object, discovery_url: str) -> "ConversationEndpoints":
if not isinstance(data, dict):
raise ValueError(str(copilot_studio_errors.InvalidDiscoveryResponse))

return cls(
create_conversation=_validate_url(
data.get("createConversation"), discovery_url, False
),
execute_turn=_validate_url(data.get("executeTurn"), discovery_url, True),
continue_turn=_validate_url(data.get("continueTurn"), discovery_url, True),
subscribe=(
_validate_url(data["subscribe"], discovery_url, True)
if data.get("subscribe") is not None
else None
),
)

def get_url(
self, operation: ConversationOperation, conversation_id: Optional[str] = None
) -> str:
if operation == "createConversation":
return self.create_conversation
if not conversation_id:
raise ValueError(str(copilot_studio_errors.DiscoveryConversationIdRequired))
url = self.execute_turn if operation == "executeTurn" else self.subscribe
if url is None:
raise NotImplementedError(
str(copilot_studio_errors.DiscoverySubscribeNotSupported)
)
return url.replace(_CONVERSATION_ID, quote(conversation_id, safe=""))
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import aiohttp
import asyncio
import json
import logging
from typing import AsyncIterable, Optional

import aiohttp

from microsoft_agents.activity import Activity, ActivityTypes, ConversationAccount

from ._conversation_endpoint_discovery import (
ConversationEndpoints,
ConversationOperation,
get_discovery_url,
)
from .agent_type import AgentType
from .connection_settings import ConnectionSettings
from .errors import copilot_studio_errors
from .execute_turn_request import ExecuteTurnRequest
from .power_platform_environment import PowerPlatformEnvironment
from .start_request import StartRequest
Expand All @@ -34,6 +44,91 @@ def __init__(
self._logger = logging.getLogger(__name__)
self.conversation_id = ""
self._island_experimental_url = ""
self._discovery_lock = asyncio.Lock()
self._discovered_direct_connect_url: Optional[str] = None
self._discovered_endpoints: Optional[ConversationEndpoints] = None

async def _resolve_connection_url(
self, conversation_id: Optional[str] = None, subscribe: bool = False
) -> str:
if (
self.settings.copilot_agent_type != AgentType.PREBUILT
and not self.settings.direct_connect_url
):
async with self._discovery_lock:
if (
self.settings.copilot_agent_type != AgentType.PREBUILT
and not self.settings.direct_connect_url
):
endpoints = await self._discover_endpoints()
if (
self.settings.copilot_agent_type != AgentType.PREBUILT
and not self.settings.direct_connect_url
):
self._discovered_endpoints = endpoints
self._discovered_direct_connect_url = (
endpoints.create_conversation
)
self.settings.direct_connect_url = endpoints.create_conversation
Comment thread
axelsrz marked this conversation as resolved.
Comment thread
axelsrz marked this conversation as resolved.

if (
self.settings.copilot_agent_type != AgentType.PREBUILT
and self._discovered_endpoints is not None
and self.settings.direct_connect_url == self._discovered_direct_connect_url
):
operation: ConversationOperation = (
"subscribe"
if subscribe
else "executeTurn" if conversation_id else "createConversation"
)
return self._discovered_endpoints.get_url(operation, conversation_id)
return PowerPlatformEnvironment.get_copilot_studio_connection_url(
settings=self.settings,
conversation_id=conversation_id,
create_subscribe_link=subscribe,
)

async def _discover_endpoints(self) -> ConversationEndpoints:
if not self._token or not self._token.strip():
raise ValueError(str(copilot_studio_errors.DiscoveryAccessTokenRequired))

url = get_discovery_url(self.settings)
headers = {
"Authorization": f"Bearer {self._token}",
"Accept": self.APPLICATION_JSON_TYPE,
"User-Agent": UserAgentHelper.get_user_agent_header(),
}
if self.settings.enable_diagnostics:
self._logger.debug(">>> SEND TO %s", url)

async with aiohttp.ClientSession(
**self.settings.client_session_settings
) as session:
async with session.get(
url, headers=headers, allow_redirects=False
) as response:
if response.status != 200:
self._logger.error(
"Error discovering conversation endpoints: %s", response.status
)
raise aiohttp.ClientResponseError(
response.request_info,
response.history,
status=response.status,
message="Error discovering conversation endpoints",
headers=response.headers,
)
try:
data = await response.json()
except (
json.JSONDecodeError,
UnicodeDecodeError,
aiohttp.ContentTypeError,
) as exc:
raise ValueError(
str(copilot_studio_errors.InvalidDiscoveryResponse)
) from exc
return ConversationEndpoints.from_response(data, url)

async def post_request(
self, url: str, data: dict, headers: dict
Expand Down Expand Up @@ -112,9 +207,7 @@ async def start_conversation(
:return: An asynchronous iterable of Activity objects received in the response.
"""

url = PowerPlatformEnvironment.get_copilot_studio_connection_url(
settings=self.settings
)
url = await self._resolve_connection_url()
data = {"emitStartConversationEvent": emit_start_conversation_event}
headers = {
"Content-Type": self.APPLICATION_JSON_TYPE,
Expand Down Expand Up @@ -164,9 +257,7 @@ async def ask_question_with_activity(
activity.conversation.id or self._current_conversation_id
)

url = PowerPlatformEnvironment.get_copilot_studio_connection_url(
settings=self.settings, conversation_id=local_conversation_id
)
url = await self._resolve_connection_url(local_conversation_id)
data = ExecuteTurnRequest(activity=activity).model_dump(
mode="json", by_alias=True, exclude_unset=True
)
Expand All @@ -188,9 +279,7 @@ async def start_conversation_with_request(
:return: An asynchronous iterable of Activity objects received in the response.
"""

url = PowerPlatformEnvironment.get_copilot_studio_connection_url(
settings=self.settings
)
url = await self._resolve_connection_url()
data = start_request.model_dump(mode="json", by_alias=True, exclude_unset=True)
headers = {
"Content-Type": self.APPLICATION_JSON_TYPE,
Expand Down Expand Up @@ -249,12 +338,7 @@ async def subscribe(
if not conversation_id:
raise ValueError("CopilotClient.subscribe: conversation_id cannot be None")

# 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,
create_subscribe_link=True,
)
url = await self._resolve_connection_url(conversation_id, subscribe=True)
headers = {
"Content-Type": self.APPLICATION_JSON_TYPE,
"Authorization": f"Bearer {self._token}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,31 @@ class CopilotStudioErrorResources:
-65006,
)

InvalidDiscoveryResponse = ErrorMessage(
"Conversation endpoint discovery returned an invalid JSON object.",
-65007,
)

InvalidDiscoveryOperationUrl = ErrorMessage(
"Conversation endpoint discovery returned an invalid operation URL.",
-65008,
)

DiscoveryConversationIdRequired = ErrorMessage(
"A conversation ID is required for this operation.",
-65009,
)

DiscoverySubscribeNotSupported = ErrorMessage(
"The agent does not advertise a subscribe endpoint.",
-65010,
)

DiscoveryAccessTokenRequired = ErrorMessage(
"A non-empty access token is required for conversation endpoint discovery.",
-65011,
)

def __init__(self):
"""Initialize CopilotStudioErrorResources."""
pass
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ def get_environment_endpoint(
if cloud == PowerPlatformCloud.OTHER and not cloud_base_address:
raise ValueError(str(copilot_studio_errors.CloudBaseAddressRequired))
cloud_base_address = cloud_base_address or "api.unknown.powerplatform.com"
cloud_base_address = urlparse(cloud_base_address).netloc or cloud_base_address
normalized_resource_id = environment_id.lower().replace("-", "")
id_suffix_length = PowerPlatformEnvironment.get_id_suffix_length(cloud)
hex_prefix = normalized_resource_id[:-id_suffix_length]
Expand Down
22 changes: 22 additions & 0 deletions libraries/microsoft-agents-copilotstudio-client/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,28 @@ def create_client():
return copilot_client
```

#### Conversation Endpoint Discovery

For published agents without a `direct_connect_url`, the client first sends an
authenticated `GET` to
`https://<environment-host>/copilotstudio/agents/<escaped-schema-name>/conversation-endpoints`.
The host is selected from the configured environment and cloud. Discovery uses the
client's bearer token and the configured `client_session_settings`.

The client validates all advertised endpoints before storing them and uses each
operation URL as returned, preserving its query string and escaping conversation
IDs when substituting `{conversationId}`. `createConversation`, `executeTurn`, and
`continueTurn` are required; `subscribe` is optional and subscription fails
explicitly when the agent does not advertise it. The client has no continue-turn
operation.

Successful discovery is reused for the lifetime of the client, with concurrent
calls coordinated to avoid duplicate initialization. Failures and cancellation
allow a later call to retry. The discovered create URL is written to
`settings.direct_connect_url`; clearing that value triggers discovery again.
Assigning a different explicit URL switches back to legacy direct-URL routing.
Explicit direct URLs and prebuilt agents bypass discovery entirely.

#### DirectConnect URL Mode (Simplified Setup)

For simplified setup, you can use a DirectConnect URL instead of environment-based configuration:
Expand Down
Loading
Loading