diff --git a/python/samples/demos/m365-agent/.env.example b/python/samples/demos/m365-agent/.env.example new file mode 100644 index 0000000000..3c21a9e91c --- /dev/null +++ b/python/samples/demos/m365-agent/.env.example @@ -0,0 +1,17 @@ +# OpenAI Configuration +OPENAI_API_KEY= +OPENAI_CHAT_MODEL_ID= + +# Agent 365 Agentic Authentication Configuration +USE_ANONYMOUS_MODE= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__SCOPES= + +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__TYPE=AgenticUserAuthorization +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__SCOPES=https://graph.microsoft.com/.default +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__ALTERNATEBLUEPRINTCONNECTIONNAME=https://graph.microsoft.com/.default + +CONNECTIONSMAP_0_SERVICEURL=* +CONNECTIONSMAP_0_CONNECTION=SERVICE_CONNECTION diff --git a/python/samples/demos/m365-agent/README.md b/python/samples/demos/m365-agent/README.md new file mode 100644 index 0000000000..ecd1e6f632 --- /dev/null +++ b/python/samples/demos/m365-agent/README.md @@ -0,0 +1,100 @@ +# Microsoft Agent Framework Python Weather Agent sample (M365 Agents SDK) + +This sample demonstrates a simple Weather Forecast Agent built with the Python Microsoft Agent Framework, exposed through the Microsoft 365 Agents SDK compatible endpoints. The agent accepts natural language requests for a weather forecast and responds with a textual answer. It supports multi-turn conversations to gather required information. + +## Prerequisites + +- Python 3.11+ +- [uv](https://github.com/astral-sh/uv) for fast dependency management +- [devtunnel](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started?tabs=windows) +- [Microsoft 365 Agents Toolkit](https://github.com/OfficeDev/microsoft-365-agents-toolkit) for playground/testing +- Access to OpenAI or Azure OpenAI with a model like `gpt-4o-mini` + +## Configuration + +Set the following environment variables: + +```bash +# Common +export PORT=3978 +export USE_ANONYMOUS_MODE=True # set to false if using auth + +# OpenAI +export OPENAI_API_KEY="..." +export OPENAI_CHAT_MODEL_ID="..." +``` + +## Installing Dependencies + +From the repository root or the sample folder: + +```bash +uv sync +``` + +## Running the Agent Locally + +```bash +# Activate environment first if not already +source .venv/bin/activate # (Windows PowerShell: .venv\Scripts\Activate.ps1) + +# Run the weather agent demo +python m365_agent_demo/app.py +``` + +The agent starts on `http://localhost:3978`. Health check: `GET /api/health`. + +## QuickStart using Agents Playground + +1. Install (if not already): + + ```bash + winget install agentsplayground + ``` + +2. Start the Python agent locally: `python m365_agent_demo/app.py` +3. Start the playground: `agentsplayground` +4. Chat with the Weather Agent. + +## QuickStart using WebChat (Azure Bot) + +To test via WebChat you can provision an Azure Bot and point its messaging endpoint to your agent. + +1. Create an Azure Bot (choose Client Secret auth for local tunneling). +2. Create a `.env` file in this sample folder with the following (replace placeholders): + + ```bash + # Authentication / Agentic configuration + USE_ANONYMOUS_MODE=False + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID="" + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET="" + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID="" + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__SCOPES=https://graph.microsoft.com/.default + + AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__TYPE=AgenticUserAuthorization + AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__SCOPES=https://graph.microsoft.com/.default + AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__ALTERNATEBLUEPRINTCONNECTIONNAME=https://graph.microsoft.com/.default + ``` + +3. Host dev tunnel: + + ```bash + devtunnel host -p 3978 --allow-anonymous + ``` + +4. Set the bot Messaging endpoint to: `https:///api/messages` +5. Run your local agent: `python 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. + +## Troubleshooting + +- 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. + +## Further Reading + +- [Microsoft 365 Agents SDK](https://learn.microsoft.com/microsoft-365/agents-sdk/) +- [Devtunnel docs](https://learn.microsoft.com/azure/developer/dev-tunnels/) diff --git a/python/samples/demos/m365-agent/m365_agent_demo/app.py b/python/samples/demos/m365-agent/m365_agent_demo/app.py new file mode 100644 index 0000000000..7580e5ecce --- /dev/null +++ b/python/samples/demos/m365-agent/m365_agent_demo/app.py @@ -0,0 +1,238 @@ +# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "microsoft-agents-hosting-aiohttp", +# "microsoft-agents-hosting-core", +# "microsoft-agents-authentication-msal", +# "microsoft-agents-activity", +# "agent-framework-core", +# "aiohttp" +# ] +# /// + +import os +from dataclasses import dataclass +from random import randint +from typing import Annotated + +from agent_framework import ChatAgent +from agent_framework.openai import OpenAIChatClient +from aiohttp import web +from aiohttp.web_middlewares import middleware +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.core import ( + AgentApplication, + AuthenticationConstants, + Authorization, + ClaimsIdentity, + MemoryStorage, + TurnContext, + TurnState, +) +from pydantic import Field + +""" +Demo application using Microsoft Agent 365 SDK. + +This sample demonstrates how to build an AI agent using the Agent Framework, +integrating with Microsoft 365 authentication and hosting components. + +The agent provides a simple weather tool and can be run in either anonymous mode +(no authentication required) or authenticated mode using MSAL and Azure AD. + +Key features: +- Loads configuration from environment variables. +- Demonstrates agent creation and tool registration. +- 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 +anonymous mode for local testing. +""" + + +@dataclass +class AppConfig: + use_anonymous_mode: bool + port: int + agents_sdk_config: dict + + +def load_app_config() -> AppConfig: + """Load application configuration from environment variables. + + Returns: + AppConfig: Consolidated configuration including anonymous mode flag, port, and SDK config. + """ + agents_sdk_config = load_configuration_from_env(os.environ) + use_anonymous_mode = os.environ.get("USE_ANONYMOUS_MODE", "true").lower() == "true" + port_str = os.getenv("PORT", "3978") + try: + port = int(port_str) + except ValueError: + port = 3978 + return AppConfig(use_anonymous_mode=use_anonymous_mode, port=port, agents_sdk_config=agents_sdk_config) + + +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Generate a mock weather report for the provided location. + + Args: + location: The geographic location name. + Returns: + str: Human-readable weather summary. + """ + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +def build_agent() -> ChatAgent: + """Create and return the chat agent instance with weather tool registered.""" + return OpenAIChatClient().create_agent( + 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. + + Args: + config: Application configuration. + Returns: + MsalConnectionManager | None: Connection manager when authenticated mode is enabled. + """ + if config.use_anonymous_mode: + return None + return MsalConnectionManager(**config.agents_sdk_config) + + +def build_adapter(connection_manager: MsalConnectionManager | None) -> CloudAdapter: + """Instantiate the CloudAdapter with the optional 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. + + Args: + storage: State storage backend. + connection_manager: Optional connection manager. + config: Application configuration. + Returns: + Authorization | None: Authorization component when enabled. + """ + 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, + config: AppConfig, +) -> AgentApplication[TurnState]: + """Compose and return the AgentApplication instance. + + Args: + storage: Storage implementation. + adapter: CloudAdapter handling requests. + authorization: Optional authorization component. + config: App configuration. + Returns: + AgentApplication[TurnState]: Configured agent application. + """ + return AgentApplication[TurnState]( + storage=storage, adapter=adapter, authorization=authorization, **config.agents_sdk_config + ) + + +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 + + +def create_app(config: AppConfig) -> web.Application: + """Create and configure the aiohttp web application. + + Args: + config: Loaded application configuration. + Returns: + web.Application: Fully initialized web application. + """ + middleware_fn = build_anonymous_claims_middleware(config.use_anonymous_mode) + app = web.Application(middlewares=[middleware_fn]) + + storage = MemoryStorage() + agent = build_agent() + connection_manager = build_connection_manager(config) + adapter = build_adapter(connection_manager) + authorization = build_authorization(storage, connection_manager, config) + agent_app = build_agent_application(storage, adapter, authorization, config) + + @agent_app.activity("message") + async def on_message(context: TurnContext, _: TurnState): + user_message = context.activity.text or "" + if not user_message.strip(): + return + + response = await agent.run(user_message) + response_text = response.text + + await context.send_activity(response_text) + + async def health(request: web.Request) -> web.Response: + return web.json_response({"status": "ok"}) + + async def entry_point(req: web.Request) -> web.Response: + return await start_agent_process(req, req.app["agent_app"], req.app["adapter"]) + + app.add_routes([ + web.get("/api/health", health), + web.get("/api/messages", lambda _: web.Response(status=200)), + web.post("/api/messages", entry_point), + ]) + + app["agent_app"] = agent_app + app["adapter"] = adapter + + return app + + +def main() -> None: + """Entry point: load configuration, build app, and start server.""" + config = load_app_config() + app = create_app(config) + web.run_app(app, host="localhost", port=config.port) + + +if __name__ == "__main__": + main()