Skip to content
Draft
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
31 changes: 11 additions & 20 deletions python/samples/05-end-to-end/m365-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ This sample demonstrates a simple Weather Forecast Agent built with the Python M
- [uv](https://github.com/astral-sh/uv) for fast dependency management
- [devtunnel](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started?tabs=windows)
- `agentsplayground` for playground/testing
- Access to OpenAI or Azure OpenAI with a model like `gpt-4o-mini`
- A Microsoft Foundry project with a deployed model like `gpt-4o-mini`
- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli), signed in with `az login`

## Configuration

Expand All @@ -19,27 +20,17 @@ Set the following environment variables:
export PORT=3978
export USE_ANONYMOUS_MODE=True # set to false if using auth

# OpenAI
export OPENAI_API_KEY="..."
export OPENAI_CHAT_COMPLETION_MODEL="..."
```

## Installing Dependencies

From the repository root or the sample folder:

```bash
uv sync
# Microsoft Foundry (authenticates with your `az login` session)
export FOUNDRY_PROJECT_ENDPOINT="https://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
export FOUNDRY_MODEL="gpt-4o-mini"
```

## Running the Agent Locally

```bash
# Activate environment first if not already
source .venv/bin/activate # (Windows PowerShell: .venv\Scripts\Activate.ps1)
`app.py` declares its dependencies (the M365 Agents SDK hosting and authentication packages, `agent-framework-foundry`, and `aiohttp`) in an inline [PEP 723](https://peps.python.org/pep-0723/) header. `uv run` installs them into an isolated environment the first time you run it. Run it from this sample folder:

# Run the weather agent demo
python m365_agent_demo/app.py
```bash
uv run m365_agent_demo/app.py
```

The agent starts on `http://localhost:3978`. Health check: `GET /api/health`.
Expand All @@ -52,7 +43,7 @@ The agent starts on `http://localhost:3978`. Health check: `GET /api/health`.
winget install agentsplayground
```

2. Start the Python agent locally: `python m365_agent_demo/app.py`
2. Start the Python agent locally: `uv run m365_agent_demo/app.py`
3. Start the playground: `agentsplayground`
4. Chat with the Weather Agent.

Expand Down Expand Up @@ -83,7 +74,7 @@ To test via WebChat you can provision an Azure Bot and point its messaging endpo
```

4. Set the bot Messaging endpoint to: `https://<tunnel-host>/api/messages`
5. Run your local agent: `python m365_agent_demo/app.py`
5. Run your local agent: `uv run m365_agent_demo/app.py`
6. Use "Test in WebChat" in Azure Portal.

> Federated Credentials or Managed Identity auth types typically require deployment to Azure App Service instead of tunneling.
Expand All @@ -92,7 +83,7 @@ To test via WebChat you can provision an Azure Bot and point its messaging endpo

- 404 on `/api/messages`: Ensure you are POSTing and using the correct tunnel URL.
- Empty responses: Check model key / quota and ensure environment variables are set.
- Auth errors when anonymous disabled: Validate MSAL config matches your Azure Bot registration.
- 401 responses when anonymous mode is disabled: requests to `/api/messages` must carry a bearer token issued for your Azure Bot. Validate that the MSAL config matches your Azure Bot registration.

## Further Reading

Expand Down
90 changes: 40 additions & 50 deletions python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "microsoft-agents-hosting-aiohttp",
# "microsoft-agents-hosting-core",
# "microsoft-agents-authentication-msal",
# "microsoft-agents-activity",
# "microsoft-agents-hosting-aiohttp>=1.4.0",
# "microsoft-agents-hosting-core>=1.4.0",
# "microsoft-agents-authentication-msal>=1.4.0",
# "microsoft-agents-activity>=1.4.0",
# "agent-framework-foundry",
# "aiohttp"
# ]
# ///
# Copyright (c) Microsoft. All rights reserved.
# Run with any PEP 723 compatible runner, e.g.:
# uv run samples/demos/m365-agent/m365_agent_demo/app.py
# uv run samples/05-end-to-end/m365-agent/m365_agent_demo/app.py

import os
from dataclasses import dataclass
Expand All @@ -22,13 +22,14 @@
from agent_framework.foundry import FoundryChatClient
from aiohttp import web
from aiohttp.web_middlewares import middleware
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from microsoft_agents.activity import load_configuration_from_env
from microsoft_agents.authentication.msal import MsalConnectionManager
from microsoft_agents.hosting.aiohttp import CloudAdapter, start_agent_process
from microsoft_agents.hosting.aiohttp import CloudAdapter, jwt_authorization_decorator, start_agent_process
from microsoft_agents.hosting.core import (
AgentApplication,
AuthenticationConstants,
AgentAuthConfiguration,
Authorization,
ClaimsIdentity,
MemoryStorage,
Expand All @@ -55,7 +56,7 @@
- Supports both anonymous and authenticated scenarios.
- Uses aiohttp for web hosting.

To run, set the appropriate environment variables (check .env.example file) for authentication or use
To run, set the environment variables listed in the README for authentication or use
anonymous mode for local testing.
"""

Expand Down Expand Up @@ -101,59 +102,61 @@ def get_weather(

def build_agent() -> Agent:
"""Create and return the chat agent instance with weather tool registered."""
_client = FoundryChatClient()
# Reads FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL from the environment; run `az login` first.
_client = FoundryChatClient(credential=AzureCliCredential())
return Agent(
client=_client, name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather
)


def build_connection_manager(config: AppConfig) -> MsalConnectionManager | None:
"""Build the connection manager unless running in anonymous mode.
def build_connection_manager(config: AppConfig) -> MsalConnectionManager:
"""Build the connection manager.

Args:
config: Application configuration.
Returns:
MsalConnectionManager | None: Connection manager when authenticated mode is enabled.
MsalConnectionManager: Connection manager configured from the environment, or an empty
placeholder connection in anonymous mode.
"""
if config.use_anonymous_mode:
return None
# CloudAdapter requires a connection manager, but anonymous requests never request tokens,
# so an empty service connection is enough for local testing.
return MsalConnectionManager(connections_configurations={"SERVICE_CONNECTION": AgentAuthConfiguration()})
return MsalConnectionManager(**config.agents_sdk_config)


def build_adapter(connection_manager: MsalConnectionManager | None) -> CloudAdapter:
"""Instantiate the CloudAdapter with the optional connection manager."""
def build_adapter(connection_manager: MsalConnectionManager) -> CloudAdapter:
"""Instantiate the CloudAdapter with the connection manager."""
return CloudAdapter(connection_manager=connection_manager)


def build_authorization(
storage: MemoryStorage, connection_manager: MsalConnectionManager | None, config: AppConfig
) -> Authorization | None:
"""Create Authorization component if not in anonymous mode.
storage: MemoryStorage, connection_manager: MsalConnectionManager, config: AppConfig
) -> Authorization:
"""Create the Authorization component.

Args:
storage: State storage backend.
connection_manager: Optional connection manager.
connection_manager: Connection manager.
config: Application configuration.
Returns:
Authorization | None: Authorization component when enabled.
Authorization: Authorization component.
"""
if config.use_anonymous_mode:
return None
return Authorization(storage, connection_manager, **config.agents_sdk_config)


def build_agent_application(
storage: MemoryStorage,
adapter: CloudAdapter,
authorization: Authorization | None,
authorization: Authorization,
config: AppConfig,
) -> AgentApplication[TurnState]:
"""Compose and return the AgentApplication instance.

Args:
storage: Storage implementation.
adapter: CloudAdapter handling requests.
authorization: Optional authorization component.
authorization: Authorization component.
config: App configuration.
Returns:
AgentApplication[TurnState]: Configured agent application.
Expand All @@ -163,30 +166,12 @@ def build_agent_application(
)


def build_anonymous_claims_middleware(use_anonymous_mode: bool):
"""Return a middleware that injects anonymous claims when enabled.

Args:
use_anonymous_mode: Whether to apply anonymous identity for each request.
Returns:
Callable: Aiohttp middleware function.
"""

@middleware
async def anonymous_claims_middleware(request, handler):
"""Inject claims for anonymous users if anonymous mode is active."""
if use_anonymous_mode:
request["claims_identity"] = ClaimsIdentity(
{
AuthenticationConstants.AUDIENCE_CLAIM: "anonymous",
AuthenticationConstants.APP_ID_CLAIM: "anonymous-app",
},
False,
"Anonymous",
)
return await handler(request)

return anonymous_claims_middleware
@middleware
async def anonymous_claims_middleware(request, handler):
"""Inject an anonymous identity for local testing without authentication."""
# An anonymous identity with no claims makes the adapter skip token acquisition.
request["claims_identity"] = ClaimsIdentity(authentication_type="Anonymous")
return await handler(request)


def create_app(config: AppConfig) -> web.Application:
Expand All @@ -197,8 +182,7 @@ def create_app(config: AppConfig) -> web.Application:
Returns:
web.Application: Fully initialized web application.
"""
middleware_fn = build_anonymous_claims_middleware(config.use_anonymous_mode)
app = web.Application(middleware=[middleware_fn])
app = web.Application(middlewares=[anonymous_claims_middleware] if config.use_anonymous_mode else [])

storage = MemoryStorage()
agent = build_agent()
Expand All @@ -224,6 +208,12 @@ async def health(request: web.Request) -> web.Response:
async def entry_point(req: web.Request) -> web.Response:
return await start_agent_process(req, req.app["agent_app"], req.app["adapter"])

if not config.use_anonymous_mode:
# Validate the bearer token on every incoming activity; requests with a missing or
# invalid token get a 401 before they reach the agent.
app["agent_configuration"] = connection_manager.get_default_connection_configuration()
entry_point = jwt_authorization_decorator(entry_point)

app.add_routes([
web.get("/api/health", health),
web.get("/api/messages", lambda _: web.Response(status=200)),
Expand Down
Loading
Loading