diff --git a/README.md b/README.md index fe4e117f..dc83e8ef 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ We offer the following PyPI packages to create conversational experiences based | `microsoft-agents-hosting-core` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-core)](https://pypi.org/project/microsoft-agents-hosting-core/) | Core library for Microsoft Agents hosting. | `botbuilder-core, botframework-connector` | | `microsoft-agents-hosting-aiohttp` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-aiohttp)](https://pypi.org/project/microsoft-agents-hosting-aiohttp/) | Configures aiohttp to run the Agent. | `botbuilder-integration-aiohttp` | | `microsoft-agents-hosting-fastapi` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-fastapi)](https://pypi.org/project/microsoft-agents-hosting-fastapi/) | Configures FastAPI to run the Agent. | N/A | +| `microsoft-agents-hosting-starlette` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-starlette)](https://pypi.org/project/microsoft-agents-hosting-starlette/) | Configures Starlette to run the Agent. | N/A | | `microsoft-agents-hosting-teams` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-teams)](https://pypi.org/project/microsoft-agents-hosting-teams/) | Provides classes to host an Agent for Teams. | N/A | | `microsoft-agents-hosting-dialogs` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-dialogs)](https://pypi.org/project/microsoft-agents-hosting-dialogs/) | Dialog system with waterfall dialogs, prompts, and multi-turn conversation management. | `botbuilder-dialogs` | | `microsoft-agents-storage-blob` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-storage-blob)](https://pypi.org/project/microsoft-agents-storage-blob/) | Extension to use Azure Blob as storage. | `botbuilder-azure` | diff --git a/libraries/microsoft-agents-hosting-starlette/LICENSE b/libraries/microsoft-agents-hosting-starlette/LICENSE new file mode 100644 index 00000000..ce29e72a --- /dev/null +++ b/libraries/microsoft-agents-hosting-starlette/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-starlette/MANIFEST.in b/libraries/microsoft-agents-hosting-starlette/MANIFEST.in new file mode 100644 index 00000000..74282fce --- /dev/null +++ b/libraries/microsoft-agents-hosting-starlette/MANIFEST.in @@ -0,0 +1 @@ +include VERSION.txt diff --git a/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/__init__.py b/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/__init__.py new file mode 100644 index 00000000..7cb68c5a --- /dev/null +++ b/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/__init__.py @@ -0,0 +1,25 @@ +from ._start_agent_process import start_agent_process +from .agent_http_adapter import AgentHttpAdapter +from .channel_service_route_table import channel_service_routes +from .cloud_adapter import CloudAdapter +from .jwt_authorization_middleware import ( + JwtAuthorizationMiddleware, +) + +# Import streaming utilities from core for backward compatibility +from microsoft_agents.hosting.core.app.streaming import ( + Citation, + CitationUtil, + StreamingResponse, +) + +__all__ = [ + "start_agent_process", + "AgentHttpAdapter", + "CloudAdapter", + "JwtAuthorizationMiddleware", + "channel_service_routes", + "Citation", + "CitationUtil", + "StreamingResponse", +] diff --git a/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/_start_agent_process.py b/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/_start_agent_process.py new file mode 100644 index 00000000..709b5eb1 --- /dev/null +++ b/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/_start_agent_process.py @@ -0,0 +1,29 @@ +from typing import Optional +from starlette.requests import Request +from starlette.responses import Response +from microsoft_agents.hosting.core import error_resources +from microsoft_agents.hosting.core.app import AgentApplication +from .cloud_adapter import CloudAdapter + + +async def start_agent_process( + request: Request, + agent_application: AgentApplication, + adapter: CloudAdapter, +) -> Optional[Response]: + """Starts the agent host with the provided adapter and agent application. + Args: + request (Request): The incoming Starlette request. + agent_application (AgentApplication): The agent application to run. + adapter (CloudAdapter): The adapter to use for the agent host. + """ + if not adapter: + raise TypeError(str(error_resources.AdapterRequired)) + if not agent_application: + raise TypeError(str(error_resources.AgentApplicationRequired)) + + # Start the agent application with the provided adapter + return await adapter.process( + request, + agent_application, + ) diff --git a/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/agent_http_adapter.py b/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/agent_http_adapter.py new file mode 100644 index 00000000..0f300d7d --- /dev/null +++ b/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/agent_http_adapter.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import abstractmethod +from typing import Optional, Protocol + +from starlette.requests import Request +from starlette.responses import Response + +from microsoft_agents.hosting.core import Agent + + +class AgentHttpAdapter(Protocol): + @abstractmethod + async def process(self, request: Request, agent: Agent) -> Optional[Response]: + raise NotImplementedError() diff --git a/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/channel_service_route_table.py b/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/channel_service_route_table.py new file mode 100644 index 00000000..45e1c698 --- /dev/null +++ b/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/channel_service_route_table.py @@ -0,0 +1,149 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route + +from microsoft_agents.hosting.core import ChannelApiHandlerProtocol +from microsoft_agents.hosting.core.http import ChannelServiceRoutes + + +class StarletteRequestAdapter: + """Adapter for Starlette requests to use with ChannelServiceRoutes.""" + + def __init__(self, request: Request): + self._request = request + + @property + def method(self) -> str: + return self._request.method + + @property + def headers(self): + return self._request.headers + + async def json(self): + return await self._request.json() + + def get_claims_identity(self): + return getattr(self._request.state, "claims_identity", None) + + def get_path_param(self, name: str) -> str: + return self._request.path_params.get(name, "") + + +# Table of channel-service endpoints: (path, http_method, ChannelServiceRoutes +# method name, returns_body). ``returns_body`` is False for endpoints that reply +# with a bare status code (no JSON body). +_ROUTES = [ + ( + "/v3/conversations/{conversation_id}/activities", + "POST", + "send_to_conversation", + True, + ), + ( + "/v3/conversations/{conversation_id}/activities/{activity_id}", + "POST", + "reply_to_activity", + True, + ), + ( + "/v3/conversations/{conversation_id}/activities/{activity_id}", + "PUT", + "update_activity", + True, + ), + ( + "/v3/conversations/{conversation_id}/activities/{activity_id}", + "DELETE", + "delete_activity", + False, + ), + ( + "/v3/conversations/{conversation_id}/activities/{activity_id}/members", + "GET", + "get_activity_members", + True, + ), + ("/", "POST", "create_conversation", True), + ("/", "GET", "get_conversations", True), + ( + "/v3/conversations/{conversation_id}/members", + "GET", + "get_conversation_members", + True, + ), + ( + "/v3/conversations/{conversation_id}/members/{member_id}", + "GET", + "get_conversation_member", + True, + ), + ( + "/v3/conversations/{conversation_id}/pagedmembers", + "GET", + "get_conversation_paged_members", + True, + ), + ( + "/v3/conversations/{conversation_id}/members/{member_id}", + "DELETE", + "delete_conversation_member", + True, + ), + ( + "/v3/conversations/{conversation_id}/activities/history", + "POST", + "send_conversation_history", + True, + ), + ( + "/v3/conversations/{conversation_id}/attachments", + "POST", + "upload_attachment", + True, + ), +] + + +def _make_endpoint( + service_routes: ChannelServiceRoutes, method_name: str, returns_body: bool +): + """Build a Starlette endpoint that dispatches to a ChannelServiceRoutes method.""" + service_method = getattr(service_routes, method_name) + + async def endpoint(request: Request): + result = await service_method(StarletteRequestAdapter(request)) + if returns_body: + return JSONResponse(content=result) + return Response(status_code=200) + + return endpoint + + +def channel_service_routes( + handler: ChannelApiHandlerProtocol, base_url: str = "" +) -> list[Route]: + """Create the list of Starlette routes for the Channel Service API. + + The returned routes can be passed to a ``Starlette`` application (via the + ``routes=`` argument) or mounted under a sub-path with ``starlette.routing.Mount``. + + Args: + handler: The handler that implements the Channel API protocol. + base_url: Optional base URL prefix for all routes. + + Returns: + A list of ``starlette.routing.Route`` for all channel service endpoints. + """ + service_routes = ChannelServiceRoutes(handler, base_url) + return [ + Route( + base_url + path, + _make_endpoint(service_routes, method_name, returns_body), + methods=[http_method], + ) + for path, http_method, method_name, returns_body in _ROUTES + ] diff --git a/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/cloud_adapter.py b/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/cloud_adapter.py new file mode 100644 index 00000000..cf4f4aa7 --- /dev/null +++ b/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/cloud_adapter.py @@ -0,0 +1,94 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from typing import Optional + +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from microsoft_agents.hosting.core import Agent +from microsoft_agents.hosting.core.authorization import Connections +from microsoft_agents.hosting.core.http import ( + HttpAdapterBase, + HttpResponse, +) +from microsoft_agents.hosting.core import ChannelServiceClientFactoryBase + +from .agent_http_adapter import AgentHttpAdapter + + +class StarletteRequestAdapter: + """Adapter to make a Starlette Request compatible with HttpRequestProtocol.""" + + def __init__(self, request: Request): + self._request = request + + @property + def method(self) -> str: + return self._request.method + + @property + def headers(self): + return self._request.headers + + async def json(self): + return await self._request.json() + + def get_claims_identity(self): + return getattr(self._request.state, "claims_identity", None) + + def get_path_param(self, name: str) -> str: + return self._request.path_params.get(name, "") + + +class CloudAdapter(HttpAdapterBase, AgentHttpAdapter): + """CloudAdapter for the Starlette web framework.""" + + def __init__( + self, + *, + connection_manager: Connections = None, + channel_service_client_factory: ChannelServiceClientFactoryBase = None, + ): + """ + Initializes a new instance of the CloudAdapter class. + + :param connection_manager: Optional connection manager for OAuth. + :param channel_service_client_factory: The factory to use to create the channel service client. + """ + super().__init__( + connection_manager=connection_manager, + channel_service_client_factory=channel_service_client_factory, + ) + + async def process(self, request: Request, agent: Agent) -> Optional[Response]: + """Process a Starlette request. + + Args: + request: The Starlette request. + agent: The agent to handle the request. + + Returns: + Starlette Response object. + """ + # Adapt request to protocol + adapted_request = StarletteRequestAdapter(request) + + # Process using base implementation + http_response: HttpResponse = await self.process_request(adapted_request, agent) + + # Convert HttpResponse to Starlette Response + return self._to_starlette_response(http_response) + + @staticmethod + def _to_starlette_response(http_response: HttpResponse) -> Response: + """Convert HttpResponse to a Starlette Response.""" + if http_response.body is not None: + return JSONResponse( + content=http_response.body, + status_code=http_response.status_code, + headers=http_response.headers, + ) + return Response( + status_code=http_response.status_code, + headers=http_response.headers, + ) diff --git a/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/jwt_authorization_middleware.py b/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/jwt_authorization_middleware.py new file mode 100644 index 00000000..6c6c2c40 --- /dev/null +++ b/libraries/microsoft-agents-hosting-starlette/microsoft_agents/hosting/starlette/jwt_authorization_middleware.py @@ -0,0 +1,75 @@ +from starlette.requests import Request +from starlette.responses import JSONResponse +import logging +from starlette.types import ASGIApp, Receive, Scope, Send +from microsoft_agents.hosting.core import ( + AgentAuthConfiguration, + JwtTokenValidator, +) + +logger = logging.getLogger(__name__) + + +class JwtAuthorizationMiddleware: + """Starlette-compatible ASGI middleware for JWT authorization. + + Usage: + from starlette.applications import Starlette + + app = Starlette() + app.add_middleware(JwtAuthorizationMiddleware) + app.state.agent_configuration = auth_config + """ + + def __init__(self, app: ASGIApp): + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send): + if scope["type"] == "lifespan": + await self.app(scope, receive, send) + return + + app = scope.get("app") + state = getattr(app, "state", None) if app else None + auth_config: AgentAuthConfiguration = getattr( + state, "agent_configuration", None + ) + + request = Request(scope, receive=receive) + token_validator = JwtTokenValidator(auth_config) + auth_header = request.headers.get("Authorization") + + if auth_header: + parts = auth_header.split(" ") + if len(parts) == 2 and parts[0].lower() == "bearer": + token = parts[1] + try: + claims = await token_validator.validate_token(token) + request.state.claims_identity = claims + except ValueError as e: + logger.warning("JWT validation error: %s", e) + response = JSONResponse( + {"error": "Invalid token or authentication failed."}, + status_code=401, + ) + await response(scope, receive, send) + return + else: + response = JSONResponse( + {"error": "Invalid authorization header format"}, + status_code=401, + ) + await response(scope, receive, send) + return + else: + if auth_config.ANONYMOUS_ALLOWED: + request.state.claims_identity = token_validator.get_anonymous_claims() + else: + response = JSONResponse( + {"error": "Authorization header not found"}, + status_code=401, + ) + await response(scope, receive, send) + return + + await self.app(scope, receive, send) diff --git a/libraries/microsoft-agents-hosting-starlette/pyproject.toml b/libraries/microsoft-agents-hosting-starlette/pyproject.toml new file mode 100644 index 00000000..3c3cafc9 --- /dev/null +++ b/libraries/microsoft-agents-hosting-starlette/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "microsoft-agents-hosting-starlette" +dynamic = ["version", "dependencies"] +description = "Integration library for Microsoft Agents with Starlette" +readme = {file = "readme.md", content-type = "text/markdown"} +authors = [{name = "Microsoft Corporation"}] +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", +] + +[project.urls] +"Homepage" = "https://github.com/microsoft/Agents" diff --git a/libraries/microsoft-agents-hosting-starlette/readme.md b/libraries/microsoft-agents-hosting-starlette/readme.md new file mode 100644 index 00000000..0c5b1708 --- /dev/null +++ b/libraries/microsoft-agents-hosting-starlette/readme.md @@ -0,0 +1,44 @@ +# Microsoft Agents Hosting Starlette + +This library provides [Starlette](https://www.starlette.io/) integration for Microsoft Agents, enabling you to +build conversational agents using the Starlette ASGI framework (or any framework built on it). + +It mirrors the `microsoft-agents-hosting-fastapi` integration but depends only on Starlette, so it can be used +directly with a bare Starlette application or embedded by other Starlette-based hosts. + +## Installation + +```bash +pip install microsoft-agents-hosting-starlette +``` + +## Usage + +```python +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Route + +from microsoft_agents.hosting.core import AgentApplication, TurnState +from microsoft_agents.hosting.starlette import ( + CloudAdapter, + JwtAuthorizationMiddleware, + start_agent_process, +) + +# Build your AgentApplication + adapter as usual. +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AGENT_APP: AgentApplication = ... + + +async def messages(request: Request) -> Response: + return await start_agent_process(request, AGENT_APP, ADAPTER) + + +app = Starlette( + routes=[Route("/api/messages", messages, methods=["POST"])], +) +app.add_middleware(JwtAuthorizationMiddleware) +app.state.agent_configuration = AGENT_APP.auth_configuration +``` diff --git a/libraries/microsoft-agents-hosting-starlette/setup.py b/libraries/microsoft-agents-hosting-starlette/setup.py new file mode 100644 index 00000000..c2a7f2f8 --- /dev/null +++ b/libraries/microsoft-agents-hosting-starlette/setup.py @@ -0,0 +1,18 @@ +from os import environ, path +from setuptools import setup + +# Try to read from VERSION.txt file first, fall back to environment variable +version_file = path.join(path.dirname(__file__), "VERSION.txt") +if path.exists(version_file): + with open(version_file, "r", encoding="utf-8") as f: + package_version = f.read().strip() +else: + package_version = environ.get("PackageVersion", "0.0.0") + +setup( + version=package_version, + install_requires=[ + f"microsoft-agents-hosting-core=={package_version}", + "starlette>=1.0.1", # CVE-2026-48710 fix + ], +)