diff --git a/python/copilot-studio/sample-agent/.env.template b/python/copilot-studio/sample-agent/.env.template new file mode 100644 index 00000000..7db985df --- /dev/null +++ b/python/copilot-studio/sample-agent/.env.template @@ -0,0 +1,106 @@ +# ============================================================================= +# Copilot Studio Sample Agent — Environment Configuration +# ============================================================================= +# Copy this file to .env and fill in your values: +# cp .env.template .env +# +# All values marked <<...>> MUST be replaced before the agent will work. +# Run `a365 setup all --agent-name "" --aiteammate` first — it generates +# the config files referenced below. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Copilot Studio Configuration +# ----------------------------------------------------------------------------- +# Choose ONE of the following approaches: + +# Option 1: Direct Connect URL (recommended for quick setup) +# Get this from Copilot Studio > Settings > Advanced > Metadata +DIRECT_CONNECT_URL= + +# Option 2: Environment ID + Agent Identifier +# Get these from Copilot Studio > Settings > Advanced > Metadata +ENVIRONMENT_ID=<> +AGENT_IDENTIFIER=<> + +# ----------------------------------------------------------------------------- +# Agent365 Service Connection (OAuth client credentials) +# ----------------------------------------------------------------------------- +# These values authenticate your agent with the Bot Framework and Agent 365. +# +# Where to find them (after running `a365 setup all`): +# CLIENTID => a365.generated.config.json → agentBlueprintId +# CLIENTSECRET => a365.generated.config.json → agentBlueprintClientSecret +# TENANTID => a365.config.json → tenantId +# +# IMPORTANT — Client Secret: +# a365.generated.config.json stores the secret encrypted (Windows DPAPI). +# Use `a365 config display -g` to view the decrypted secret. +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=<> +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=<> +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=<> +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__SCOPES=5a807f24-c9de-44ee-a3a7-329e88a00ffc/.default + +# Connection mapping (do not change) +CONNECTIONSMAP__0__CONNECTION=SERVICE_CONNECTION +CONNECTIONSMAP__0__SERVICEURL=* + +# ----------------------------------------------------------------------------- +# Agent Identity +# ----------------------------------------------------------------------------- +# At runtime, tenant_id and agent_id are extracted from the incoming +# TurnContext activity (context.activity.recipient). Env vars are fallbacks +# for local dev / Agents Playground where the activity may lack these fields. +AGENTIC_TENANT_ID=<> +AGENTIC_UPN=<> +AGENTIC_NAME=<> + +# ----------------------------------------------------------------------------- +# Server +# ----------------------------------------------------------------------------- +# Port for the aiohttp server. +# Local dev default: 3978. Azure App Service injects PORT automatically. +PORT=3978 + +# ----------------------------------------------------------------------------- +# Authentication +# ----------------------------------------------------------------------------- +# AUTH_HANDLER_NAME controls authentication mode: +# "" — anonymous (Agents Playground / local dev) +# "AGENTIC" — production (Teams / Azure deployment) +# +# IMPORTANT: Must be uppercase "AGENTIC" to match the handler key in +# AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__... +AUTH_HANDLER_NAME= + +# Server JWT authentication (required when AUTH_HANDLER_NAME=AGENTIC) +CLIENT_ID=<> +TENANT_ID=<> +CLIENT_SECRET=<> + +# Agentic user-authorization handler settings (do not change these defaults) +AGENT_ID=<> +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__TYPE=AgenticUserAuthorization +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__ALT_BLUEPRINT_NAME=SERVICE_CONNECTION +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__SCOPES=https://graph.microsoft.com/.default + +# (Optional) Bearer token for local dev without agentic auth. +# Obtain with: a365 develop get-token -o raw +# BEARER_TOKEN= + +# ----------------------------------------------------------------------------- +# Observability +# ----------------------------------------------------------------------------- +ENABLE_OBSERVABILITY=true +ENABLE_A365_OBSERVABILITY_EXPORTER=false +ENABLE_KAIRO_EXPORTER=false +A365_OBSERVABILITY_LOG_LEVEL=info + +# Observability identity (values from a365 setup all) +AGENT365OBSERVABILITY__AGENTID=<> +AGENT365OBSERVABILITY__AGENTNAME=<> +AGENT365OBSERVABILITY__AGENTDESCRIPTION=<> +AGENT365OBSERVABILITY__TENANTID=<> +AGENT365OBSERVABILITY__AGENTBLUEPRINTID=<> +AGENT365OBSERVABILITY__CLIENTID=<> +AGENT365OBSERVABILITY__CLIENTSECRET=<> diff --git a/python/copilot-studio/sample-agent/.gitignore b/python/copilot-studio/sample-agent/.gitignore new file mode 100644 index 00000000..f2ab9783 --- /dev/null +++ b/python/copilot-studio/sample-agent/.gitignore @@ -0,0 +1,43 @@ +# Environment +.env +.venv/ +venv/ +env/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +dist/ +build/ +*.egg + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Agent 365 generated / local state +a365.config.json +a365.generated.config.json +chat.json +deploy.zip +devTools/ +manifest/ + +# Logs +*.log + +# Testing +.pytest_cache/ +htmlcov/ +.coverage +coverage.xml diff --git a/python/copilot-studio/sample-agent/README.md b/python/copilot-studio/sample-agent/README.md new file mode 100644 index 00000000..1a144a39 --- /dev/null +++ b/python/copilot-studio/sample-agent/README.md @@ -0,0 +1,543 @@ +# Copilot Studio Sample Agent - Python + +This sample demonstrates how to bridge a **Microsoft Copilot Studio** low-code agent into the **Microsoft Agent 365** managed environment using the [Microsoft Agent 365 SDK for Python](https://github.com/microsoft/Agent365-python). Every message arriving through Agent 365 channels (Teams, email, etc.) is forwarded to your published Copilot Studio agent, and the response is relayed back — giving low-code agents access to enterprise identity, notifications, observability, and the full Agent 365 lifecycle. + +This sample uses the [`microsoft-agents-copilotstudio-client`](https://pypi.org/project/microsoft-agents-copilotstudio-client/) package for Copilot Studio connectivity and the [`microsoft-opentelemetry`](https://pypi.org/project/microsoft-opentelemetry/) distro for end-to-end observability. + +For comprehensive documentation and guidance on building agents with the Microsoft Agent 365 SDK, including how to add tooling, observability, and notifications, visit the [Microsoft Agent 365 Developer Documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/). + +## Demonstrates + +- **Copilot Studio Integration** — Forward messages to a published low-code Copilot Studio agent via `CopilotClient` +- **Notifications** — Handle email notifications from Agent 365 and return responses via `EmailResponse` +- **Observability** — End-to-end tracing with `InferenceScope`, `BaggageBuilder`, and `AgenticTokenCache` +- **Hosting Patterns** — Hosting with the Microsoft 365 Agents SDK (Python / aiohttp) including typing indicators and multiple-message patterns + +## Prerequisites + +- **Python 3.11+** +- **[uv](https://docs.astral.sh/uv/)** package manager (recommended) or pip +- **Microsoft Copilot Studio** access (Frontier preview program) +- A **published Copilot Studio agent** with authentication configured +- **Azure / Microsoft 365 tenant** with administrative permissions +- **Microsoft 365 Copilot license** (required to publish agents in Copilot Studio) +- **[Node.js](https://nodejs.org/)** (for Agents Playground) + +## Copilot Studio Setup + +Before running this sample you need a published Copilot Studio agent: + +1. Go to [Copilot Studio](https://copilotstudio.microsoft.com/) +2. Create a new agent (or use an existing one) +3. Configure authentication: **Microsoft Entra ID** → **Require users to sign in** +4. Publish your agent +5. Go to **Settings → Advanced → Metadata** and copy: + - **Environment ID** + - **Schema Name** (agent identifier) +6. Alternatively, copy the **Direct Connect URL** from the agent's channel settings + +## Required Setup Steps + +### 1. Add CopilotStudio.Copilots.Invoke API Permission + +The `CopilotStudio.Copilots.Invoke` scope must be added to your agent's blueprint. The fastest way is via the A365 CLI: + +```bash +a365 setup permissions copilotstudio +``` + +Or manually in the Azure Portal: + +1. Go to [Azure Portal](https://portal.azure.com/) → **Microsoft Entra ID** → **App registrations** +2. Select your agent's blueprint app registration +3. Go to **API permissions → Add a permission** +4. Select **APIs my organization uses** → search for **Power Platform API** +5. Add the `CopilotStudio.Copilots.Invoke` **delegated** permission +6. **Grant admin consent** for the permission + +### 2. Grant User Access to the Copilot Studio Agent + +Users must have access to chat with your Copilot Studio agent: + +- **Option A: Organization-wide access** (used in this sample) + 1. Open your agent in Copilot Studio + 2. Click **…** (three dots) → **Share** + 3. Select the option to share with everyone in your organization + +- **Option B: Security group access** + 1. Create a security group in Microsoft Entra ID + 2. Add users who need access to the group + 3. Share the agent with that security group in Copilot Studio + +> **Note:** Individual users cannot be granted access directly — you must use security groups or organization-wide sharing. Authentication must be configured with Microsoft Entra ID and "Require users to sign in" enabled. + +For more details, see [Share agents with other users](https://learn.microsoft.com/en-us/microsoft-copilot-studio/admin-share-bots). + +### 3. Microsoft 365 Copilot License Requirement + +A Microsoft 365 Copilot license is required to publish agents in Copilot Studio. Ensure your tenant has the appropriate licensing before attempting to publish your agent. + +## Quick Start — Local Development + +### 1. Clone and set up the environment + +```bash +cd python/copilot-studio/sample-agent + +# Create virtual environment and install dependencies +uv venv +uv sync +``` + +### 2. Configure environment variables + +Copy the template and fill in your values: + +```bash +cp .env.template .env +``` + +Minimum required for local/Playground testing: + +```env +ENVIRONMENT_ID= +AGENT_IDENTIFIER= +AUTH_HANDLER_NAME= # leave empty for Playground/local dev +BEARER_TOKEN= +``` + +> **Note:** `AUTH_HANDLER_NAME` must be empty for Agents Playground. Setting it to `AGENTIC` requires a real AAD token that Playground does not provide. + +### 3. Initialize A365 configuration + +The fastest way is the AI-guided setup — attach the instruction file to GitHub Copilot Chat (agent mode) and it walks you through every step automatically: + +``` +Follow the steps in #file:a365-setup-instructions.md +``` + +> See [AI-guided setup for Agent 365](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/ai-guided-setup) for full instructions and to download `a365-setup-instructions.md`. + +Alternatively, run the CLI manually: + +```bash +a365 setup all --agent-name "" --aiteammate +``` + +### 4. Run the agent + +```bash +# Activate the virtual environment +.venv/Scripts/activate # Windows +source .venv/bin/activate # Linux / macOS + +# Start the server (listens on localhost:3978) +python main.py +``` + +You should see: + +``` +Copilot Studio Sample Agent (Python) +Auth: Anonymous +Server: localhost:3978 +Endpoint: http://localhost:3978/api/messages +Health: http://localhost:3978/api/health +``` + +### 5. Get a bearer token for Copilot Studio (required) + +To authenticate with Copilot Studio locally without agentic auth, get a fresh token: + +```bash +a365 develop get-token -o raw +``` + +Copy the output and set it in `.env`: + +```env +BEARER_TOKEN= +``` + +The token expires in ~90 minutes. + +## Testing with Agents Playground + +The Agents Playground is a local testing tool that connects directly to your running agent — no tunnel or deployment required. + +### Install + +```bash +# Via npm (recommended) +npm install -g @microsoft/m365agentsplayground + +# Or via winget (Windows) +winget install agentsplayground +``` + +### Run locally (anonymous mode) + +1. Start your agent: + +```bash +python main.py +``` + +2. In a separate terminal, launch the Playground: + +```bash +agentsplayground -e "http://localhost:3978/api/messages" -c "emulator" +``` + +3. The Playground opens in your browser — start chatting with your agent. + +### Run with authentication + +```bash +agentsplayground -e "http://localhost:3978/api/messages" -c "emulator" \ + --client-id "" \ + --client-secret "" \ + --tenant-id "" +``` + +### Testing checklist + +| Test | How | +|---|---| +| Basic message | Send any text message in the Playground chat | +| Install/uninstall | Agents Playground → Mock an Activity → Install application | +| Typing indicator | Send a message — you should see "Got it — working on it…" then "..." animation | +| Health endpoint | Navigate to `http://localhost:3978/api/health` | +| User identity | Check server logs for `Turn received from user — DisplayName:` | + +## Deploying to Production + +### Full lifecycle with A365 CLI + +```bash +# 1. Provision all cloud resources, blueprint, and permissions +a365 setup all --agent-name "" --aiteammate + +# 2. Add Copilot Studio permission (if not done during setup) +a365 setup permissions copilotstudio + +# 3. Publish agent — creates manifest package for upload to M365 Admin Center +a365 publish --agent-name "" --aiteammate +``` + +### Running on Azure App Service + +See [Deploy agent to Azure](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/deploy-agent-azure?tabs=dotnet) for full instructions. + +Deploy using Azure CLI: + +```bash +# Create the App Service (first time only) +az webapp create \ + --name \ + --resource-group \ + --runtime "PYTHON:3.13" \ + --sku B1 + +# Set the startup command +az webapp config set \ + --name \ + --resource-group \ + --startup-file "python main.py" + +# Set Application Settings (see table below) +az webapp config appsettings set \ + --name \ + --resource-group \ + --settings AUTH_HANDLER_NAME=AGENTIC ENABLE_OBSERVABILITY=true ... + +# Deploy code via zip deploy +az webapp deploy \ + --name \ + --resource-group \ + --src-path deploy.zip +``` + +> **Port:** Azure App Service injects `PORT=8000` automatically. The app reads it from the environment — do not hardcode `3978` in any startup command. + +### Configure Application Settings + +The `.env` file is not deployed. Set all variables as Azure App Service Application Settings. + +All values below come from `a365.config.json` and `a365.generated.config.json` (produced by `a365 setup all`). Run `a365 config display -g` to view the decrypted generated values. + +| Setting | Source | Example | +|---|---|---| +| `ENVIRONMENT_ID` | Copilot Studio → Metadata | `Default-xxxxxxxx-...` | +| `AGENT_IDENTIFIER` | Copilot Studio → Metadata | `cr0b4_YourAgent` | +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID` | `a365.generated.config.json` → `agentBlueprintId` | Blueprint App ID | +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET` | `a365.generated.config.json` → `agentBlueprintClientSecret` | Blueprint client secret | +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID` | `a365.config.json` → `tenantId` | Azure tenant ID | +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__SCOPES` | — | `5a807f24-c9de-44ee-a3a7-329e88a00ffc/.default` | +| `AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__TYPE` | — | `AgenticUserAuthorization` | +| `AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__ALT_BLUEPRINT_NAME` | — | `SERVICE_CONNECTION` | +| `AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__SCOPES` | — | `https://graph.microsoft.com/.default` | +| `AUTH_HANDLER_NAME` | — | `AGENTIC` | +| `CLIENT_ID` | `a365.generated.config.json` → `agentBlueprintId` | Blueprint App ID | +| `TENANT_ID` | `a365.config.json` → `tenantId` | Azure tenant ID | +| `CLIENT_SECRET` | `a365.generated.config.json` → `agentBlueprintClientSecret` | Blueprint client secret | +| `AGENTIC_TENANT_ID` | `a365.config.json` → `tenantId` | Azure tenant ID | +| `ENABLE_OBSERVABILITY` | — | `true` | +| `ENABLE_A365_OBSERVABILITY_EXPORTER` | — | `true` | + +### Messaging endpoint reference + +See [Configure messaging endpoint](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/agent-messaging-endpoint) for all hosting options. + +| Platform | Endpoint | needDeployment | +|---|---|---| +| Azure App Service | `https://.azurewebsites.net/api/messages` | `true` | +| Dev Tunnel (local) | `https://.devtunnels.ms:3978/api/messages` | `false` | + +## After Publishing — Post-Deployment Steps + +After `a365 setup all` and `a365 publish` complete, and the code is deployed to Azure App Service, the following steps require browser interaction and cannot be automated by the CLI. + +### Step 1: Configure in Teams Developer Portal + +1. Get your blueprint App ID: + +```bash +a365 config display -g +``` + +Copy the `agentBlueprintId` value from the output. + +2. Open your blueprint configuration page: + +``` +https://dev.teams.microsoft.com/tools/agent-blueprint//configuration +``` + +3. Set Agent Type to `Bot Based` +4. Set Bot ID to your `agentBlueprintId` +5. Click Save + +> See [Configure agent in Teams Developer Portal](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/create-instance#1-configure-agent-in-teams-developer-portal) and [Publish agent](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/publish) for full instructions. + +### Step 2: Upload manifest to M365 Admin Center + +1. Go to [https://admin.microsoft.com](https://admin.microsoft.com) > Agents > All agents > Upload custom agent +2. Upload `manifest/manifest.zip` (created by `a365 publish`) + +### Step 3: Create agent instance + +1. In Microsoft Teams, go to Apps and search for your agent name +2. Select your agent and click Request Instance +3. A tenant admin must approve the request at: +``` +https://admin.cloud.microsoft/#/agents/all/requested +``` + +### Step 4: Verify the deployed endpoint + +After the instance is approved, send a message to the agent from Teams. Check the App Service logs for: + +```text +Observability identity — agent_id: '', tenant_id: '', source: activity.recipient +Exporting 1 spans to endpoint: https://agent365.svc.cloud.microsoft/... +HTTP 200 success ... rejectedSpans: 0 +``` + +The sample reads agent and tenant identity from the incoming `TurnContext` activity at runtime. No static `AGENTIC_USER_ID` setting is required for observability export. + +## Configuration Reference + +All configuration is via environment variables (`.env` for local, App Settings for Azure): + +| Variable | Default | Description | +|---|---|---| +| `DIRECT_CONNECT_URL` | _(none)_ | Copilot Studio Direct Connect URL (alternative to Environment ID + Agent Identifier) | +| `ENVIRONMENT_ID` | _(required)_ | Copilot Studio Environment ID | +| `AGENT_IDENTIFIER` | _(required)_ | Copilot Studio Schema Name | +| `AUTH_HANDLER_NAME` | _(empty)_ | Empty = anonymous (Playground/local), `AGENTIC` = production | +| `BEARER_TOKEN` | _(none)_ | Token for local dev without agentic auth. Get with `a365 develop get-token -o raw` | +| `AGENTIC_TENANT_ID` | _(from activity)_ | Azure tenant ID (fallback for Playground) | +| `CLIENT_ID` / `TENANT_ID` / `CLIENT_SECRET` | _(required for production)_ | JWT validation credentials for incoming Bot Framework traffic | +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID` / `CLIENTSECRET` / `TENANTID` | _(required for production)_ | Service connection used by the Agent 365 SDK | +| `AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__TYPE` | `AgenticUserAuthorization` | Configures the agentic OBO auth handler | +| `AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__ALT_BLUEPRINT_NAME` | `SERVICE_CONNECTION` | Service connection name used for agentic auth | +| `AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__SCOPES` | `https://graph.microsoft.com/.default` | Default user auth scopes for the handler | +| `ENABLE_OBSERVABILITY` | `true` | Enable OpenTelemetry tracing | +| `ENABLE_A365_OBSERVABILITY_EXPORTER` | `false` | Send traces to A365 backend (`true` for production) | +| `ENABLE_KAIRO_EXPORTER` | `false` | Enables the Kairo exporter when supported by the environment | +| `PORT` | `3978` | Server port (Azure sets this to 8000 automatically) | + +## Working with User Identity + +On every incoming message, the A365 platform populates `activity.from_property` with basic user information — always available with no API calls or token acquisition: + +| Field | Description | +|---|---| +| `activity.from_property.id` | Channel-specific user ID (e.g., `29:1AbcXyz...` in Teams) | +| `activity.from_property.name` | Display name as known to the channel | +| `activity.from_property.aad_object_id` | Azure AD Object ID — use this to call Microsoft Graph | + +The sample logs these fields at the start of every message turn in `agent.py`. + +## Handling Agent Install and Uninstall + +When a user installs (hires) or uninstalls (removes) the agent, the A365 platform sends an `InstallationUpdate` activity. The sample handles this in `on_installation_update` in `main.py`: + +| Action | Behavior | +|---|---| +| `add` | Agent was installed — send a welcome message | +| `remove` | Agent was uninstalled — send a farewell message | + +To test with Agents Playground, use Mock an Activity → Install application to send a simulated `installationUpdate` activity. + +## Sending Multiple Messages in Teams + +Agent365 agents can send multiple discrete messages in response to a single user prompt. This is the recommended pattern for agentic identities in Teams. + +> **Important:** Streaming (SSE) is not supported for agentic identities in Teams. Instead, call `send_activity` multiple times. + +### Pattern + +1. Send an immediate acknowledgment so the user knows work has started +2. Run a typing indicator loop — each indicator times out after ~5 seconds, so re-send every ~4 seconds +3. Do your LLM work, then send the response + +### Code Example + +```python +# Multiple messages: send an immediate ack before the LLM work begins. +# Each send_activity call produces a discrete Teams message. +await context.send_activity("Got it — working on it…") + +# Send typing indicator immediately (awaited so it arrives before the LLM call starts). +await context.send_activity(Activity(type="typing")) + +# Background loop refreshes the "..." animation every ~4s (it times out after ~5s). +# asyncio.create_task is used because all aiohttp handlers share the same event loop. +async def _typing_loop(): + while True: + try: + await asyncio.sleep(4) + await context.send_activity(Activity(type="typing")) + except asyncio.CancelledError: + break + +typing_task = asyncio.create_task(_typing_loop()) +try: + response = await agent.process_user_message(user_message, ...) + await context.send_activity(response) +finally: + typing_task.cancel() + try: + await typing_task + except asyncio.CancelledError: + pass +``` + +### Typing Indicators + +- Typing indicators show a progress animation in Teams +- They have a built-in ~5-second visual timeout — re-send every ~4 seconds for long operations +- Only visible in 1:1 chats and small group chats (not channels) + +## Architecture + +### File Structure + +``` +sample-agent/ +├── main.py # Server entry point — CopilotStudioAgentHost, handlers, observability +├── agent.py # MyAgent — message/notification routing logic +├── client.py # McsClient — CopilotClient wrapper with InferenceScope +├── .env.template # Environment variable template +├── pyproject.toml # Project metadata and dependencies +├── requirements.txt # Pip-compatible dependency list (for Azure Oryx build) +├── ToolingManifest.json # A365 tooling manifest (empty — proxies to Copilot Studio) +└── docs/ + └── design.md # Detailed design document +``` + +### Message Flow + +``` +User ──▶ Agent 365 SDK ──▶ main.py (CopilotStudioAgentHost) + │ + ├─▶ agent.py (MyAgent.process_user_message) + │ │ + │ └─▶ client.py (get_client → McsClient) + │ │ + │ └─▶ CopilotClient ──▶ Copilot Studio API + │ ◀── response ──┘ + │ ◀── response ──┘ + ◀── send_activity ──┘ +User ◀── +``` + +## Troubleshooting + +### Common Issues + +| Issue | Cause | Solution | +|---|---|---| +| `403 Forbidden` from Copilot Studio | Missing permission or user access | Run `a365 setup permissions copilotstudio` and share the agent org-wide | +| `Auth handler AGENTIC not recognized` | Auth handler name case mismatch | Ensure `AUTH_HANDLER_NAME=AGENTIC` (uppercase) matches `AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__...` | +| `No auth handler and no BEARER_TOKEN` | Anonymous mode with no fallback | Set `AUTH_HANDLER_NAME=AGENTIC` for production, or provide `BEARER_TOKEN` for local dev | +| Agent not responding in Playground | `AUTH_HANDLER_NAME=AGENTIC` is set | Clear `AUTH_HANDLER_NAME` for Playground — it doesn't provide a real AAD token | +| Observability export 400 "Tenant id is invalid" | Missing `BaggageBuilder` context | Ensure `AGENTIC_TENANT_ID` is set as a fallback for Playground | +| `ConnectionSettings` validation error | Neither `DIRECT_CONNECT_URL` nor `ENVIRONMENT_ID`+`AGENT_IDENTIFIER` set | Set one of the two options in `.env` | +| Azure container startup timeout (230s) | Wrong port | `main.py` reads `PORT` from env — Azure sets `PORT=8000` automatically | +| `No response from Copilot Studio agent` | Agent not published | Publish your agent in Copilot Studio and verify the Schema Name | + +### Checking Logs + +Enable debug logging for the observability exporter to verify telemetry is flowing: + +``` +INFO microsoft.opentelemetry...agent365_exporter: Exporting 1 spans to endpoint: https://agent365.svc.cloud.microsoft/... +DEBUG microsoft.opentelemetry...agent365_exporter: HTTP 200 success. Response: {"partialSuccess":{"rejectedSpans":0}} +``` + +For a detailed explanation of the agent code and implementation, see the [Design Document](docs/design.md). + +## Support + +For issues, questions, or feedback: + +- **Issues:** Please file issues in the [GitHub Issues](https://github.com/microsoft/Agent365-Samples/issues) section +- **Documentation:** See the [Microsoft Agent 365 Developer documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/) +- **Copilot Studio:** See [Copilot Studio documentation](https://learn.microsoft.com/en-us/microsoft-copilot-studio/) +- **Security:** For security issues, please see [SECURITY.md](../../../../SECURITY.md) + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit [https://cla.opensource.microsoft.com](https://cla.opensource.microsoft.com/). + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Additional Resources + +- [Microsoft Agent 365 SDK — Python repository](https://github.com/microsoft/Agent365-python) +- [Microsoft 365 Agents SDK — Python repository](https://github.com/Microsoft/Agents-for-python) +- [`microsoft-agents-copilotstudio-client` on PyPI](https://pypi.org/project/microsoft-agents-copilotstudio-client/) +- [Copilot Studio documentation](https://learn.microsoft.com/en-us/microsoft-copilot-studio/) +- [Configure messaging endpoint](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/agent-messaging-endpoint) +- [Deploy agent to Azure](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/deploy-agent-azure?tabs=dotnet) +- [Publish agent](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/publish) +- [Configure agent in Teams Developer Portal](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/create-instance#1-configure-agent-in-teams-developer-portal) +- [Configure Agent Testing](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/testing?tabs=python) +- [Test your agent locally in Agents Playground](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/test-with-toolkit-project) +- [Python API documentation](https://learn.microsoft.com/python/api/?view=m365-agents-sdk&preserve-view=true) +- [Share agents with other users](https://learn.microsoft.com/en-us/microsoft-copilot-studio/admin-share-bots) + +## Trademarks + +Microsoft, Windows, Microsoft Azure and/or other Microsoft products and services referenced in the documentation may be either trademarks or registered trademarks of Microsoft in the United States and/or other countries. The licenses for this project do not grant you rights to use any Microsoft names, logos, or trademarks. Microsoft's general trademark guidelines can be found at [http://go.microsoft.com/fwlink/?LinkID=254653](http://go.microsoft.com/fwlink/?LinkID=254653). + +## License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the MIT License - see the [LICENSE](../../../../LICENSE.md) file for details. diff --git a/python/copilot-studio/sample-agent/ToolingManifest.json b/python/copilot-studio/sample-agent/ToolingManifest.json new file mode 100644 index 00000000..e3d8deb6 --- /dev/null +++ b/python/copilot-studio/sample-agent/ToolingManifest.json @@ -0,0 +1,4 @@ +{ + "version": "1.0", + "tools": [] +} diff --git a/python/copilot-studio/sample-agent/agent.py b/python/copilot-studio/sample-agent/agent.py new file mode 100644 index 00000000..581db403 --- /dev/null +++ b/python/copilot-studio/sample-agent/agent.py @@ -0,0 +1,152 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +MyAgent – Agent 365 sample that integrates with Microsoft Copilot Studio. + +This agent demonstrates how to: +- Receive notifications from Agent 365 (email, Teams, etc.) +- Forward messages to a Copilot Studio agent +- Return responses through the Agent 365 SDK +- Integrate with Agent 365 Observability +""" + +import logging +from typing import Optional + +from microsoft_agents.hosting.core import Authorization, TurnContext +from microsoft_agents_a365.notifications.agent_notification import ( + AgentNotificationActivity, + NotificationTypes, +) + +from client import get_client + +logger = logging.getLogger(__name__) + + +class MyAgent: + """ + Copilot Studio proxy agent. + + Implements the same interface expected by + :func:`host_agent_server.create_and_run_host`. + """ + + def __init__(self) -> None: + self.logger = logging.getLogger(self.__class__.__name__) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def initialize(self) -> None: + """Called once by the host before the first request.""" + logger.info("Copilot Studio agent initialized") + + async def cleanup(self) -> None: + """Called on server shutdown.""" + logger.info("Copilot Studio agent cleanup completed") + + # ------------------------------------------------------------------ + # Message handling + # ------------------------------------------------------------------ + + async def process_user_message( + self, + message: str, + auth: Authorization, + auth_handler_name: Optional[str], + context: TurnContext, + ) -> str: + """ + Forward *message* to the Copilot Studio agent and return its response. + """ + # Log user identity – populated by the A365 platform on every turn. + from_prop = context.activity.from_property + logger.info( + "Turn received from user — DisplayName: '%s', UserId: '%s', AadObjectId: '%s'", + getattr(from_prop, "name", None) or "(unknown)", + getattr(from_prop, "id", None) or "(unknown)", + getattr(from_prop, "aad_object_id", None) or "(none)", + ) + + try: + client = await get_client(auth, auth_handler_name, context) + response = await client.invoke_inference_scope(message, context) + return response + except Exception as exc: + logger.exception("Copilot Studio query error") + return f"Error communicating with Copilot Studio: {exc}" + + # ------------------------------------------------------------------ + # Notification handling + # ------------------------------------------------------------------ + + async def handle_agent_notification_activity( + self, + notification_activity: AgentNotificationActivity, + auth: Authorization, + auth_handler_name: Optional[str], + context: TurnContext, + ) -> str: + """ + Route agent notifications to the appropriate handler. + """ + notification_type = notification_activity.notification_type + logger.info("Processing notification: %s", notification_type) + + if notification_type == NotificationTypes.EMAIL_NOTIFICATION: + return await self._handle_email_notification( + notification_activity, auth, auth_handler_name, context + ) + + # Generic / unsupported notification types + logger.info("Received notification of type: %s", notification_type) + return f"Received notification of type: {notification_type}" + + # ------------------------------------------------------------------ + # Email notification + # ------------------------------------------------------------------ + + async def _handle_email_notification( + self, + activity: AgentNotificationActivity, + auth: Authorization, + auth_handler_name: Optional[str], + context: TurnContext, + ) -> str: + """ + Handle email notifications by forwarding the email content to + Copilot Studio and returning the response. + """ + email = getattr(activity, "email", None) + if not email: + return "I could not find the email notification details." + + try: + client = await get_client(auth, auth_handler_name, context) + + # Build a prompt with the email context + sender_name = ( + getattr(context.activity.from_property, "name", None) + or "unknown sender" + ) + email_id = getattr(email, "id", "") + conversation_id = getattr(email, "conversation_id", "") + + email_prompt = ( + f"You have received an email from {sender_name}. " + f"Email ID: '{email_id}', " + f"Conversation ID: '{conversation_id}'. " + "Please process this email and provide a helpful response." + ) + + response = await client.invoke_inference_scope(email_prompt, context) + return ( + response + or "I have processed your email but do not have a response at this time." + ) + except Exception as exc: + logger.exception("Email notification error") + return "Unable to process your email at this time." diff --git a/python/copilot-studio/sample-agent/client.py b/python/copilot-studio/sample-agent/client.py new file mode 100644 index 00000000..d58ad22d --- /dev/null +++ b/python/copilot-studio/sample-agent/client.py @@ -0,0 +1,220 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Client wrapper for Microsoft Copilot Studio agents. + +Provides a thin abstraction over :class:`CopilotClient` that adds +Agent 365 Observability inference-scope telemetry to every call. +""" + +import logging +import os +import time +from typing import Protocol + +from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.hosting.core import Authorization, TurnContext +from microsoft_agents.copilotstudio.client import ( + CopilotClient, + ConnectionSettings, +) + +# Observability imports — use the Microsoft OpenTelemetry distro package +from microsoft.opentelemetry.a365.core import ( + InferenceScope, + InferenceCallDetails, + InferenceOperationType, + AgentDetails, + Request, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Public interface +# --------------------------------------------------------------------------- + + +class Client(Protocol): + """Interface for interacting with Copilot Studio agents.""" + + async def invoke_agent(self, message: str) -> str: + """Send a message and return the agent's response text.""" + ... + + async def invoke_inference_scope(self, prompt: str, context: TurnContext) -> str: + """Send a message wrapped in an observability inference scope.""" + ... + + +# --------------------------------------------------------------------------- +# Copilot Studio client implementation +# --------------------------------------------------------------------------- + + +class McsClient: + """ + Microsoft Copilot Studio (MCS) client with observability spans. + + The ``Mcs`` prefix indicates that this client is specific to Copilot Studio + agents, extending :class:`CopilotClient` with Agent 365 Observability + instrumentation. + """ + + def __init__(self, client: CopilotClient) -> None: + self._client = client + self._conversation_id: str = "" + + # -- core send ---------------------------------------------------------- + + async def invoke_agent(self, message: str) -> str: + """ + Send *message* to the Copilot Studio agent and collect the response. + + If no conversation has been started yet the first call will + automatically start one via :pymethod:`start_conversation`. + """ + responses: list[str] = [] + + try: + # Start conversation if needed + if not self._conversation_id: + async for activity in self._client.start_conversation( + emit_start_conversation_event=True, + ): + if hasattr(activity, "conversation") and activity.conversation: + conv_id = getattr(activity.conversation, "id", None) + if conv_id: + self._conversation_id = conv_id + if activity.type == ActivityTypes.message and activity.text: + responses.append(activity.text) + + # Build user activity + user_activity = Activity( + type=ActivityTypes.message, + text=message, + conversation={"id": self._conversation_id}, + ) + + # Send message and collect responses + async for activity in self._client.send_activity(user_activity): + if activity.type == ActivityTypes.message and activity.text: + responses.append(activity.text) + + return "\n".join(responses) or "No response from Copilot Studio agent." + + except Exception: + logger.exception("Error sending message to Copilot Studio") + raise + + # -- observability wrapper ---------------------------------------------- + + async def invoke_inference_scope(self, prompt: str, context: "TurnContext") -> str: + """ + Send *prompt* wrapped in an Agent 365 Observability inference scope. + + Records input/output messages, response ID, and finish reasons as + telemetry attributes. + """ + # Read identity from the incoming activity (set by the A365 platform). + recipient = context.activity.recipient + agent_id = ( + getattr(recipient, "agentic_app_id", None) + or os.getenv("AGENTIC_APP_ID", "") + ) + tenant_id = ( + getattr(recipient, "tenant_id", None) + or os.getenv("AGENTIC_TENANT_ID", "") + ) + + inference_details = InferenceCallDetails( + operationName=InferenceOperationType.CHAT, + model="copilot-studio-agent", + providerName="CopilotStudio", + ) + + agent_details = AgentDetails( + agent_id=agent_id, + agent_name="Copilot Studio Sample Agent", + tenant_id=tenant_id, + ) + + request = Request( + content=prompt, + conversation_id=self._conversation_id or f"conv-{int(time.time() * 1000)}", + ) + + response = "" + # InferenceScope.start(request, details, agent_details) — positional args + with InferenceScope.start( + request, inference_details, agent_details + ) as scope: + try: + response = await self.invoke_agent(prompt) + scope.record_input_messages([prompt]) + scope.record_output_messages([response]) + scope.record_finish_reasons(["stop"]) + except Exception as exc: + scope.record_error(exc) + raise + + return response + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +async def get_client( + authorization: Authorization, + auth_handler_name: str | None, + turn_context: TurnContext, +) -> McsClient: + """ + Create a configured :class:`McsClient`. + + Acquires an OBO token for the Power Platform audience and initialises the + underlying :class:`CopilotClient`. + + Parameters + ---------- + authorization: + Agent 365 authorization context for token acquisition. + auth_handler_name: + The name of the auth handler to use (typically ``'agentic'``). + turn_context: + Bot Framework turn context for the current conversation. + """ + # Load Copilot Studio connection settings from environment + settings_dict = ConnectionSettings.populate_from_environment() + settings = ConnectionSettings(**settings_dict) + + # Acquire token for Copilot Studio API + if auth_handler_name: + token_result = await authorization.exchange_token( + turn_context, + scopes=["https://api.powerplatform.com/.default"], + auth_handler_id=auth_handler_name, + ) + if not token_result or not token_result.token: + raise RuntimeError( + "Failed to acquire token for Copilot Studio. " + "User may need to sign in." + ) + token = token_result.token + else: + # Fallback for local dev without agentic auth + token = os.getenv("BEARER_TOKEN", "") + if not token: + raise RuntimeError( + "No auth handler and no BEARER_TOKEN set. " + "Cannot authenticate to Copilot Studio." + ) + + # Create the Copilot Studio client with the token + copilot_client = CopilotClient(settings, token) + + return McsClient(copilot_client) diff --git a/python/copilot-studio/sample-agent/docs/design.md b/python/copilot-studio/sample-agent/docs/design.md new file mode 100644 index 00000000..daa844a3 --- /dev/null +++ b/python/copilot-studio/sample-agent/docs/design.md @@ -0,0 +1,216 @@ +# Copilot Studio Sample Agent — Design Document + +## Overview + +This sample demonstrates how to bridge a **Microsoft Copilot Studio** low-code agent into the **Microsoft Agent 365** managed environment. It acts as a thin proxy: every user message arriving through Agent 365 channels (Teams, email, etc.) is forwarded to a published Copilot Studio agent, and the response is relayed back to the user. + +The integration gives low-code agents access to enterprise features they cannot reach on their own — Microsoft 365 notifications, OpenTelemetry observability, agentic authentication with Entra ID, and the full Agent 365 lifecycle. + +## What This Sample Demonstrates + +- Copilot Studio integration with Agent 365 +- Agentic authentication with Power Platform audience (`https://api.powerplatform.com/.default`) +- Email notification handling via `AgentNotification` +- Microsoft Agent 365 observability with `AgenticTokenCache` +- Multiple-message and typing-indicator patterns for Teams + +## Architecture + +``` +┌────────────┐ ┌──────────────────────────────────────────────┐ ┌──────────────────┐ +│ Teams / │ │ This Sample │ │ Copilot Studio │ +│ Email / │ │ │ │ (Low-code Agent) │ +│ Channels │ │ main.py │ │ │ +│ │ ──▶ │ └─ CopilotStudioAgentHost │ │ │ +│ │ │ ├─ AgentApplication (M365 Agents SDK)│ │ │ +│ │ │ ├─ AgentNotification (email handler) │ │ │ +│ │ │ ├─ Observability (AgenticTokenCache) │ │ │ +│ │ │ └─ on_message / on_notification │ │ │ +│ │ │ │ │ │ │ +│ │ │ ▼ │ │ │ +│ │ │ agent.py │ │ │ +│ │ │ └─ MyAgent │ │ │ +│ │ │ ├─ process_user_message() │ │ │ +│ │ │ └─ handle_agent_notification_activity│ │ │ +│ │ │ │ │ │ │ +│ │ │ ▼ │ │ │ +│ │ │ client.py │ │ │ +│ │ │ ├─ get_client() ─ token exchange (OBO) │ │ │ +│ │ │ └─ McsClient │ │ │ +│ │ │ ├─ invoke_agent() │ ──▶ │ CopilotClient │ +│ │ │ └─ invoke_inference_scope() │ │ (Direct Line) │ +│ │ │ (InferenceScope telemetry) │ │ │ +│ │ ◀── │ │ ◀── │ │ +└────────────┘ └──────────────────────────────────────────────┘ └──────────────────┘ +``` + +## Key Components + +### main.py + +Server entry point and hosting logic: +- Bootstraps observability via `use_microsoft_opentelemetry()` with `AgenticTokenCache` +- Creates `CopilotStudioAgentHost` which wires `AgentApplication`, `CloudAdapter`, `Authorization`, and `AgentNotification` +- Registers `on_message` and `on_notification` handlers +- Manages JWT middleware with health-endpoint bypass +- Runs a typing indicator loop via `asyncio.create_task` + +### agent.py + +Agent logic: +- `MyAgent` class with `process_user_message()` and `handle_agent_notification_activity()` +- Delegates to `McsClient` via the `get_client()` factory +- Routes notifications by `NotificationTypes` (currently handles `EMAIL_NOTIFICATION`) +- Builds context-rich prompts from email metadata + +### client.py + +Copilot Studio client wrapper: +- `McsClient` wraps `CopilotClient` with conversation management and observability +- `invoke_agent()` starts a conversation (if needed) and sends a user activity +- `invoke_inference_scope()` wraps `invoke_agent()` in an `InferenceScope` span +- `get_client()` factory acquires an OBO token for `https://api.powerplatform.com/.default` + +## Copilot Studio–Specific Patterns + +### CopilotClient & ConnectionSettings + +```python +from microsoft_agents.copilotstudio.client import CopilotClient, ConnectionSettings + +settings_dict = ConnectionSettings.populate_from_environment() +settings = ConnectionSettings(**settings_dict) +copilot_client = CopilotClient(settings, token) +``` + +`ConnectionSettings` reads `DIRECT_CONNECT_URL` or `ENVIRONMENT_ID` + `AGENT_IDENTIFIER` from the environment. + +### Token Exchange for Power Platform Audience + +Copilot Studio requires an OBO token scoped to the Power Platform API: + +```python +token_result = await authorization.exchange_token( + turn_context, + scopes=["https://api.powerplatform.com/.default"], + auth_handler_id=auth_handler_name, # "AGENTIC" +) +``` + +The auth handler is configured via environment variables: + +``` +AUTH_HANDLER_NAME=AGENTIC +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__TYPE=AgenticUserAuthorization +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__ALT_BLUEPRINT_NAME=SERVICE_CONNECTION +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__SCOPES=https://graph.microsoft.com/.default +``` + +> **Important:** `AUTH_HANDLER_NAME` must be uppercase `AGENTIC` to match the key in `AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__...`. Python dict lookup is case-sensitive. + +## Observability + +### AgenticTokenCache Pattern + +The sample uses the recommended `AgenticTokenCache` pattern from the [Microsoft OpenTelemetry Distro docs](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/microsoft-opentelemetry?tabs=python): + +1. **Sync resolver** (`_sync_token_resolver`) — called by the OpenTelemetry batch exporter thread. Returns a cached token. +2. **Async refresh** (`_setup_observability_token`) — called on each incoming turn. Uses `AgenticTokenCache.register_observability()` to acquire/refresh the token via OBO auth from the `TurnContext`. +3. **`use_microsoft_opentelemetry()`** — bootstraps the distro with `enable_a365=True` and the sync resolver. + +No static `A365_AGENT_APP_INSTANCE_ID` / `A365_AGENTIC_USER_ID` environment variables are needed — tokens are acquired at runtime from the incoming activity. + +### BaggageBuilder + +Every handler wraps its work in a `BaggageBuilder` context that propagates `tenant_id` and `agent_id` through all downstream spans: + +```python +with BaggageBuilder().tenant_id(tenant_id).agent_id(agent_id).build(): + # all spans inside carry tenant + agent context +``` + +Values are read from `context.activity.recipient` at runtime, with env var fallbacks for Playground. + +### InferenceScope + +`McsClient.invoke_inference_scope()` creates an `InferenceScope` span that records: +- `InferenceCallDetails` (operation type, model name, provider) +- Input / output messages +- Finish reasons +- Errors (if any) + +## Message Flow + +### Direct Message (Teams / Chat) + +``` +1. HTTP POST /api/messages +2. CopilotStudioAgentHost validates agent, sets up observability context +3. Immediate ack: "Got it — working on it…" + typing indicator +4. MyAgent.process_user_message() called +5. get_client() acquires OBO token → creates McsClient +6. McsClient.invoke_inference_scope() opens InferenceScope span +7. CopilotClient sends message to Copilot Studio API +8. Response recorded in span, sent back to user +``` + +### Email Notification + +``` +1. Agent 365 delivers email notification to on_notification +2. MyAgent routes to _handle_email_notification() +3. Context-rich prompt built from email metadata +4. Prompt forwarded to Copilot Studio via McsClient +5. Response wrapped in EmailResponse activity and sent back +``` + +## Configuration + +| Section | Variables | +|---|---| +| Copilot Studio | `DIRECT_CONNECT_URL` or `ENVIRONMENT_ID` + `AGENT_IDENTIFIER` | +| Service Connection | `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID/CLIENTSECRET/TENANTID` | +| Authentication | `AUTH_HANDLER_NAME`, `AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__*` | +| Observability | `ENABLE_OBSERVABILITY`, `ENABLE_A365_OBSERVABILITY_EXPORTER` | +| Server | `PORT` | + +See `.env.template` for the full list with descriptions. + +## Dependencies + +```toml +[project] +dependencies = [ + "microsoft-agents-hosting-aiohttp", + "microsoft-agents-hosting-core", + "microsoft-agents-authentication-msal", + "microsoft-agents-activity", + "microsoft-agents-copilotstudio-client", + "microsoft-agents-a365-notifications", + "microsoft-agents-a365-runtime >= 0.1.0", + "microsoft-opentelemetry >= 0.1.0a3", + "python-dotenv", + "aiohttp", +] +``` + +## Running the Agent + +```bash +# Setup +a365 setup all --agent-name "" --aiteammate + +# Run locally +python main.py + +# Publish +a365 publish --agent-name "" --aiteammate +``` + +## Extension Points + +1. **Additional notification types** — Add handlers for `TEAMS_NOTIFICATION`, `WPX_COMMENT`, etc. +2. **Multi-turn conversations** — Store `conversation_id` in `TurnState` or external storage +3. **MCP tools** — Add MCP tool servers alongside the Copilot Studio proxy for hybrid patterns +4. **Multiple Copilot Studio agents** — Route messages to different agents based on intent +5. **Custom telemetry** — Add custom spans or register additional exporters diff --git a/python/copilot-studio/sample-agent/main.py b/python/copilot-studio/sample-agent/main.py new file mode 100644 index 00000000..948f4642 --- /dev/null +++ b/python/copilot-studio/sample-agent/main.py @@ -0,0 +1,496 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Copilot Studio Sample Agent – Server Entry Point + +Hosts :class:`MyAgent` using the Microsoft 365 Agents SDK (aiohttp adapter) +with Agent 365 Observability, Notification handling, and JWT authentication. +""" + +# It is important to load environment variables before importing other modules. +import asyncio +import logging +import os +import socket +from os import environ + +from aiohttp.web import Application, Request, Response, json_response, run_app +from aiohttp.web_middlewares import middleware as web_middleware +from dotenv import load_dotenv + +load_dotenv() + +from microsoft_agents.activity import ( # noqa: E402 + load_configuration_from_env, + Activity, + ActivityTypes, +) +from microsoft_agents.authentication.msal import MsalConnectionManager # noqa: E402 +from microsoft_agents.hosting.aiohttp import ( # noqa: E402 + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) +from microsoft_agents.hosting.core import ( # noqa: E402 + AgentApplication, + AgentAuthConfiguration, + AuthenticationConstants, + Authorization, + ClaimsIdentity, + MemoryStorage, + TurnContext, + TurnState, +) +from microsoft_agents_a365.notifications.agent_notification import ( # noqa: E402 + AgentNotification, + NotificationTypes, + AgentNotificationActivity, + ChannelId, +) +from microsoft_agents_a365.notifications import EmailResponse # noqa: E402 +from microsoft.opentelemetry import use_microsoft_opentelemetry # noqa: E402 +from microsoft.opentelemetry.a365.core import BaggageBuilder # noqa: E402 +from microsoft.opentelemetry.a365.hosting.token_cache_helpers import ( # noqa: E402 + AgenticTokenCache, + AgenticTokenStruct, +) +from microsoft.opentelemetry.a365.runtime import ( # noqa: E402 + get_observability_authentication_scope, +) + +from agent import MyAgent # noqa: E402 + + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) + +ms_agents_logger = logging.getLogger("microsoft_agents") +ms_agents_logger.setLevel(logging.INFO) + +# Observability / exporter — DEBUG to see export HTTP responses +logging.getLogger("microsoft_agents_a365.observability").setLevel(logging.DEBUG) +logging.getLogger("microsoft.opentelemetry").setLevel(logging.DEBUG) + +logger = logging.getLogger(__name__) + +agents_sdk_config = load_configuration_from_env(environ) + +# --------------------------------------------------------------------------- +# Observability token cache (docs: "Agentic token cache with Agent Framework apps") +# --------------------------------------------------------------------------- +# The token is acquired at runtime from TurnContext (incoming message) — NOT +# from static env vars. The sync resolver is called by the batch exporter +# thread; the async handler refreshes the cache on each turn. +_token_cache = AgenticTokenCache() +_cached_tokens: dict[tuple[str, str], str | None] = {} + + +def _sync_token_resolver(agent_id: str, tenant_id: str) -> str | None: + """Sync resolver called by the OpenTelemetry exporter thread.""" + return _cached_tokens.get((agent_id, tenant_id)) + + +# --------------------------------------------------------------------------- +# Agent host +# --------------------------------------------------------------------------- + + +class CopilotStudioAgentHost: + """Hosts the Copilot Studio sample agent.""" + + # -- init --------------------------------------------------------------- + + def __init__(self) -> None: + # Auth handler name — defaults to empty (no auth handler) + # Set AUTH_HANDLER_NAME=AGENTIC for production agentic auth + self.auth_handler_name: str | None = os.getenv("AUTH_HANDLER_NAME", "") or None + if self.auth_handler_name: + logger.info("Using auth handler: %s", self.auth_handler_name) + else: + logger.info("No auth handler configured (AUTH_HANDLER_NAME not set)") + + self.agent_instance: MyAgent | None = None + + self.storage = MemoryStorage() + self.connection_manager = MsalConnectionManager(**agents_sdk_config) + self.adapter = CloudAdapter(connection_manager=self.connection_manager) + self.authorization = Authorization( + self.storage, self.connection_manager, **agents_sdk_config + ) + self.agent_app: AgentApplication[TurnState] = AgentApplication[TurnState]( + storage=self.storage, + adapter=self.adapter, + authorization=self.authorization, + **agents_sdk_config, + ) + self.agent_notification = AgentNotification(self.agent_app) + self._setup_handlers() + logger.info("Notification handlers registered successfully") + + # -- observability token ------------------------------------------------ + + async def _setup_observability_token( + self, context: TurnContext, tenant_id: str, agent_id: str + ) -> None: + """Register observability token from the TurnContext (incoming message). + + Uses the AgenticTokenCache pattern from the docs — the token is + acquired at runtime via OBO auth, not from static env vars. + """ + if not self.auth_handler_name: + logger.debug("No auth handler — skipping observability token registration") + return + + try: + _token_cache.register_observability( + agent_id=agent_id, + tenant_id=tenant_id, + token_generator=AgenticTokenStruct( + authorization=self.agent_app.auth, + turn_context=context, + ), + observability_scopes=get_observability_authentication_scope(), + ) + token = await _token_cache.get_observability_token(agent_id, tenant_id) + _cached_tokens[(agent_id, tenant_id)] = token.token if token else None + logger.debug( + "Observability token refreshed for agent_id=%s, tenant_id=%s", + agent_id, tenant_id, + ) + except Exception: + logger.warning("Failed to refresh observability token", exc_info=True) + + async def _validate_agent_and_setup_context(self, context: TurnContext): + """Validate agent availability and set up observability context.""" + # Playground sends a minimal recipient (id + name only). + # Fall back to env vars so observability baggage is still populated. + # NOTE: Use agentic_app_id (app instance ID), NOT agentic_user_id. + recipient = context.activity.recipient + tenant_id = ( + getattr(recipient, "tenant_id", None) + or os.getenv("AGENTIC_TENANT_ID", "") + ) + agent_id = ( + getattr(recipient, "agentic_app_id", None) + or os.getenv("AGENTIC_APP_ID", "") + ) + logger.info( + "Observability identity — agent_id: '%s', tenant_id: '%s', source: %s", + agent_id, + tenant_id, + "activity.recipient" if getattr(recipient, "agentic_app_id", None) else "env", + ) + + if not self.agent_instance: + logger.error("Agent not available") + await context.send_activity("Sorry, the agent is not available.") + return None + + await self._setup_observability_token(context, tenant_id, agent_id) + return tenant_id, agent_id + + # -- handler registration ----------------------------------------------- + + def _setup_handlers(self) -> None: + handler_config = ( + {"auth_handlers": [self.auth_handler_name]} + if self.auth_handler_name + else {} + ) + + # --- Installation Update (hire / remove) --- + @self.agent_app.activity("installationUpdate") + async def on_installation_update(context: TurnContext, _: TurnState) -> None: + action = context.activity.action + from_prop = context.activity.from_property + logger.info( + "InstallationUpdate — Action: '%s', DisplayName: '%s', UserId: '%s'", + action or "(none)", + getattr(from_prop, "name", "(unknown)") if from_prop else "(unknown)", + getattr(from_prop, "id", "(unknown)") if from_prop else "(unknown)", + ) + if action == "add": + await context.send_activity( + "Thank you for hiring me! Looking forward to assisting you " + "in your professional journey!" + ) + elif action == "remove": + await context.send_activity( + "Thank you for your time, I enjoyed working with you." + ) + + # --- Direct messages --- + @self.agent_app.activity("message", **handler_config) + async def on_message(context: TurnContext, _: TurnState) -> None: + try: + result = await self._validate_agent_and_setup_context(context) + if result is None: + return + tenant_id, agent_id = result + + with BaggageBuilder().tenant_id(tenant_id).agent_id(agent_id).build(): + user_message = context.activity.text or "" + if not user_message.strip(): + await context.send_activity( + "Please send me a message and I'll forward it to Copilot Studio!" + ) + return + + # Multiple messages pattern: immediate ack + await context.send_activity("Got it — working on it…") + await context.send_activity(Activity(type="typing")) + + # Typing indicator loop — refreshes the "..." animation + # every ~4 s (it times out after ~5 s). Only visible in + # 1:1 and small group chats. + async def _typing_loop() -> None: + try: + while True: + await asyncio.sleep(4) + await context.send_activity(Activity(type="typing")) + except asyncio.CancelledError: + pass # Expected on cancel. + + typing_task = asyncio.create_task(_typing_loop()) + try: + response = await self.agent_instance.process_user_message( + user_message, + self.agent_app.auth, + self.auth_handler_name, + context, + ) + await context.send_activity(response) + finally: + typing_task.cancel() + try: + await typing_task + except asyncio.CancelledError: + pass + + except Exception as exc: + logger.exception("Error handling message") + await context.send_activity( + f"Sorry, I encountered an error: {exc}" + ) + + # --- Agent notifications (email, Teams, etc.) --- + @self.agent_notification.on_agent_notification( + channel_id=ChannelId(channel="agents", sub_channel="*"), + **handler_config, + ) + async def on_notification( + context: TurnContext, + state: TurnState, + notification_activity: AgentNotificationActivity, + ) -> None: + try: + result = await self._validate_agent_and_setup_context(context) + if result is None: + return + tenant_id, agent_id = result + + with BaggageBuilder().tenant_id(tenant_id).agent_id(agent_id).build(): + logger.info( + "Notification: %s", notification_activity.notification_type + ) + + response = ( + await self.agent_instance.handle_agent_notification_activity( + notification_activity, + self.agent_app.auth, + self.auth_handler_name, + context, + ) + ) + + if ( + notification_activity.notification_type + == NotificationTypes.EMAIL_NOTIFICATION + ): + response_activity = ( + EmailResponse.create_email_response_activity(response) + ) + await context.send_activity(response_activity) + return + + await context.send_activity(response) + + except Exception as exc: + logger.exception("Notification error") + await context.send_activity( + f"Sorry, I encountered an error processing the notification: {exc}" + ) + + # -- agent lifecycle ---------------------------------------------------- + + async def initialize_agent(self) -> None: + if self.agent_instance is None: + logger.info("Initializing MyAgent...") + self.agent_instance = MyAgent() + await self.agent_instance.initialize() + + async def cleanup(self) -> None: + if self.agent_instance: + try: + await self.agent_instance.cleanup() + except Exception as exc: + logger.error("Cleanup error: %s", exc) + + # -- auth config -------------------------------------------------------- + + def create_auth_configuration(self) -> AgentAuthConfiguration | None: + client_id = environ.get("CLIENT_ID") + tenant_id = environ.get("TENANT_ID") + client_secret = environ.get("CLIENT_SECRET") + + if client_id and tenant_id and client_secret: + logger.info("Using Client Credentials authentication") + return AgentAuthConfiguration( + client_id=client_id, + tenant_id=tenant_id, + client_secret=client_secret, + scopes=["5a807f24-c9de-44ee-a3a7-329e88a00ffc/.default"], + ) + + if environ.get("BEARER_TOKEN"): + logger.info("Anonymous dev mode") + else: + logger.warning("No auth env vars; running anonymous") + return None + + # -- HTTP server -------------------------------------------------------- + + def start_server(self, auth_configuration: AgentAuthConfiguration | None = None) -> None: + async def entry_point(req: Request) -> Response: + return await start_agent_process( + req, req.app["agent_app"], req.app["adapter"] + ) + + async def health(_req: Request) -> Response: + return json_response( + { + "status": "healthy", + "agent_type": "CopilotStudioAgent", + "agent_initialized": self.agent_instance is not None, + "timestamp": __import__("datetime").datetime.utcnow().isoformat(), + } + ) + + middlewares: list = [] + if auth_configuration: + + @web_middleware + async def jwt_with_health_bypass(request, handler): + # Skip JWT for health endpoint so container orchestrators + # (Azure Container Apps, Kubernetes, App Service) can probe. + if request.path == "/api/health": + return await handler(request) + return await jwt_authorization_middleware(request, handler) + + middlewares.append(jwt_with_health_bypass) + + @web_middleware + async def anonymous_claims(request, handler): + if not auth_configuration: + request["claims_identity"] = ClaimsIdentity( + { + AuthenticationConstants.AUDIENCE_CLAIM: "anonymous", + AuthenticationConstants.APP_ID_CLAIM: "anonymous-app", + }, + False, + "Anonymous", + ) + return await handler(request) + + middlewares.append(anonymous_claims) + + app = Application(middlewares=middlewares) + app.router.add_post("/api/messages", entry_point) + app.router.add_get("/api/messages", lambda _: Response(status=200)) + app.router.add_get("/api/health", health) + + app["agent_configuration"] = auth_configuration + app["agent_app"] = self.agent_app + app["adapter"] = self.agent_app.adapter + + app.on_startup.append(lambda _app: self.initialize_agent()) + app.on_shutdown.append(lambda _app: self.cleanup()) + + is_production = ( + environ.get("WEBSITE_SITE_NAME") is not None # Azure App Service + or environ.get("K_SERVICE") is not None # GCP Cloud Run + or environ.get("ENVIRONMENT", "").lower() == "production" + ) + host = "0.0.0.0" if is_production else "localhost" + + port_str = environ.get("PORT") + if port_str: + try: + port = int(port_str) + logger.info("Using PORT from environment: %d", port) + except ValueError: + logger.warning("Invalid PORT value '%s', using default 3978", port_str) + port = 3978 + else: + port = 3978 + # Simple port availability check (only for local dev) + if not is_production: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.5) + if s.connect_ex(("127.0.0.1", port)) == 0: + port += 1 + + print("=" * 80) + print("Copilot Studio Sample Agent (Python)") + print("=" * 80) + print(f"Auth: {'Enabled' if auth_configuration else 'Anonymous'}") + print(f"Server: {host}:{port}") + print(f"Endpoint: http://{host}:{port}/api/messages") + print(f"Health: http://{host}:{port}/api/health") + print() + + try: + run_app(app, host=host, port=port, handle_signals=True) + except KeyboardInterrupt: + print("\nServer stopped") + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> None: + # Configure observability from .env + # ENABLE_OBSERVABILITY=true/false controls whether tracing is set up. + if environ.get("ENABLE_OBSERVABILITY", "true").lower() == "true": + # Use the Microsoft OpenTelemetry Distro with AgenticTokenCache. + # The token resolver is sync (called from the exporter thread); + # the cache is refreshed async in each message handler turn. + use_microsoft_opentelemetry( + enable_a365=True, + enable_azure_monitor=False, + a365_token_resolver=_sync_token_resolver, + ) + logger.info( + "Observability configured via Microsoft OpenTelemetry Distro " + "(enable_a365=True, token_resolver=AgenticTokenCache, a365_exporter=%s)", + environ.get("ENABLE_A365_OBSERVABILITY_EXPORTER", "false"), + ) + else: + logger.info("Observability disabled (ENABLE_OBSERVABILITY=false)") + + host = CopilotStudioAgentHost() + auth_config = host.create_auth_configuration() + host.start_server(auth_config) + + +if __name__ == "__main__": + main() diff --git a/python/copilot-studio/sample-agent/pyproject.toml b/python/copilot-studio/sample-agent/pyproject.toml new file mode 100644 index 00000000..3eeddb07 --- /dev/null +++ b/python/copilot-studio/sample-agent/pyproject.toml @@ -0,0 +1,54 @@ +[project] +name = "copilot-studio-sample-agent" +version = "0.1.0" +description = "Sample agent integrating Microsoft Copilot Studio with the Microsoft Agent 365 SDK (Python)" +authors = [ + { name = "Microsoft", email = "support@microsoft.com" } +] +license = { text = "MIT" } +requires-python = ">=3.11" +dependencies = [ + # Microsoft Agents SDK — hosting and integration + "microsoft-agents-hosting-aiohttp", + "microsoft-agents-hosting-core", + "microsoft-agents-authentication-msal", + "microsoft-agents-activity", + + # Copilot Studio client + "microsoft-agents-copilotstudio-client", + + # Microsoft Agent 365 SDK packages + "microsoft-agents-a365-notifications", + "microsoft-agents-a365-runtime >= 0.1.0", + + # Microsoft OpenTelemetry Distro — provides observability, BaggageBuilder, InferenceScope + "microsoft-opentelemetry >= 0.1.0a3", + + # Core dependencies + "python-dotenv", + "aiohttp", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24.0", + "ruff>=0.1.0", + "mypy>=1.0.0", +] + +# Allow pre-release versions for Microsoft Agent 365 SDK packages +[tool.uv] +prerelease = "allow" + +[[tool.uv.index]] +name = "pypi" +url = "https://pypi.org/simple" +default = true + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +only-include = ["agent.py", "client.py", "main.py"] diff --git a/python/copilot-studio/sample-agent/requirements.txt b/python/copilot-studio/sample-agent/requirements.txt new file mode 100644 index 00000000..d777758d --- /dev/null +++ b/python/copilot-studio/sample-agent/requirements.txt @@ -0,0 +1,10 @@ +microsoft-agents-hosting-aiohttp +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-activity +microsoft-agents-copilotstudio-client +microsoft-agents-a365-notifications +microsoft-agents-a365-runtime>=0.1.0 +microsoft-opentelemetry>=0.1.0a3 +python-dotenv +aiohttp