diff --git a/eng/ignore-links.txt b/eng/ignore-links.txt index 0dd024dbec83..591326cbec42 100644 --- a/eng/ignore-links.txt +++ b/eng/ignore-links.txt @@ -6,3 +6,6 @@ http://localhost:8000/samples/pyodide_integration https://pypi.org/project/azure-mynewpackage/ https://aka.ms/azsdk/python/migrate/my-new-package https://github.com/Azure/azure-sdk-for-python/compare/main.. +https://aka.ms/azsdk/foundry/hosted-agents +https://aka.ms/azsdk/foundry/activity-protocol +https://aka.ms/azsdk/foundry/quickstarts diff --git a/sdk/agentserver/azure-ai-agentserver-activity/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-activity/CHANGELOG.md new file mode 100644 index 000000000000..66d72b72187c --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/CHANGELOG.md @@ -0,0 +1,49 @@ +# Release History + +## 1.0.0b4 (Unreleased) + +### Other Changes + +- Internal refactor only; no public API changes. Consolidated the default storage + backend resolution to a single place (`ActivityAgentServerHost._resolve_storage`) + and made the internal M365 stack builder require a resolved `storage`. Renamed + internal symbols for naming consistency (framework adapter and bridge helpers). + +## 1.0.0b2 (Unreleased) + +### Features Added + +- Container protocol version `2.0.0` support: reads `x-agent-user-id` and `x-agent-foundry-call-id` from inbound requests and binds them to the request-scoped platform context so the per-request call ID is forwarded on outbound Foundry 1P calls (`x-agent-user-id` is not forwarded to 1P). The values are available to handler and tool code via `azure.ai.agentserver.core.get_request_context()`. +- Added the module-level `get_hosted_agent_env(*, digital_worker=False)` helper, which returns a config mapping (`os.environ` overlaid with the derived `CONNECTIONS__*` settings from the Foundry-native `FOUNDRY_AGENT_*` env vars) **without mutating the process environment**. Pass its result to `load_configuration_from_env(...)`. The default construction path derives this for you; call it yourself only when you build the `MsalConnectionManager` (or a pre-built `AgentApplication`) manually. The outbound-auth client id and hosting flag are now captured once at build time and threaded into request handling, so per-request auth never reads process-global environment state. + +### Breaking Changes + +- Custom handlers and pre-built `AgentApplication` injection now use dedicated + factory classmethods instead of `__init__` keyword arguments, so an invalid + combination of construction options cannot be expressed: + - `ActivityAgentServerHost(handler=fn)` → `ActivityAgentServerHost.from_request_handler(fn)` + - `ActivityAgentServerHost(agent_app=app)` → `ActivityAgentServerHost.from_agent_application(app)` + The default constructor now accepts only the build-the-M365-stack options + (`digital_worker`, `storage`, `connection_manager`, `adapter`, + `authorization`, `config`). `ActivityAgentServerHost()` (simple Teams + agent) is unchanged. +- Removed the lazy M365 initialization and the ``@app.activity(...)`` / ``@app.error`` host decorators in their old form. When constructed directly (no `from_request_handler`), the M365 Agents SDK is now initialized eagerly during `ActivityAgentServerHost(...)` construction and the built `AgentApplication` is exposed as the `app.agent_app` property. Register handlers on it with `@app.agent_app.activity(...)` / `@app.agent_app.error`, and reach the rest of the M365 surface (`message`/`proactive`/`auth` ...) the same way; you can also capture it (`agent_app = app.agent_app`) and use it standalone. The adapter is available via `app.adapter`. + - The previous implicit attribute delegation (`__getattr__` forwarding `app.activity` to the underlying `AgentApplication`) was removed in favor of the explicit, statically-typed `app.agent_app` property. Update `@app.activity(...)` → `@app.agent_app.activity(...)` and `@app.error` → `@app.agent_app.error`. +- Removed the public `apply_msal_patches()` export. The MSAL/FMI patch is now applied internally (digital-worker model only) during construction. +- Replaced the `request.state.user_isolation_key` / `request.state.chat_isolation_key` request-state fields with the request-scoped platform context (`get_request_context()` exposes `user_id` / `call_id`) per container protocol version `2.0.0`. Requires `azure-ai-agentserver-core>=2.0.0b7`. + +## 1.0.0b1 (2026-06-09) + +### Features Added + +- Initial preview release of `azure-ai-agentserver-activity`. +- `ActivityAgentServerHost` — Starlette-based host for Activity Protocol traffic. +- `POST /activity/messages` and `POST /api/messages` endpoints with Foundry platform header contract. +- Decorator API: `@app.activity(type)` and `@app.error` for zero-config handler registration. +- Custom handler support: `ActivityAgentServerHost(handler=fn)` for full M365 SDK control. +- Auto-initialization of M365 Agents SDK from environment variables (decorator mode). +- MSAL auth patches for Foundry container MAIB auth (`apply_msal_patches()`). +- Session ID resolution (query param → header → config → UUID fallback). +- Activity ID and session ID sanitization for header injection defense. +- OpenTelemetry distributed tracing and W3C Baggage propagation. +- Error-source classification (`x-platform-error-source`) on all error responses. diff --git a/sdk/agentserver/azure-ai-agentserver-activity/LICENSE b/sdk/agentserver/azure-ai-agentserver-activity/LICENSE new file mode 100644 index 000000000000..4c3581d3b052 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation. + +MIT License + +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. diff --git a/sdk/agentserver/azure-ai-agentserver-activity/MANIFEST.in b/sdk/agentserver/azure-ai-agentserver-activity/MANIFEST.in new file mode 100644 index 000000000000..61da8b18f0e1 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/MANIFEST.in @@ -0,0 +1,8 @@ +include *.md +include LICENSE +recursive-include tests *.py +recursive-include samples *.py *.md +include azure/__init__.py +include azure/ai/__init__.py +include azure/ai/agentserver/__init__.py +include azure/ai/agentserver/activity/py.typed diff --git a/sdk/agentserver/azure-ai-agentserver-activity/README.md b/sdk/agentserver/azure-ai-agentserver-activity/README.md new file mode 100644 index 000000000000..564e7bea1786 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/README.md @@ -0,0 +1,161 @@ +# Azure AI Agent Server Activity client library for Python + +The `azure-ai-agentserver-activity` package provides the Foundry container integration host for Activity Protocol traffic in Azure AI Hosted Agent containers. It plugs into [`azure-ai-agentserver-core`](https://pypi.org/project/azure-ai-agentserver-core/) and exposes a protocol endpoint with Foundry-required header, tracing, and error behavior. + +## Getting started + +### Install the package + +```bash +pip install azure-ai-agentserver-activity +``` + +### Prerequisites + +- Python 3.10 or later + +## Key concepts + +### ActivityAgentServerHost + +`ActivityAgentServerHost` is an `AgentServerHost` subclass for Activity Protocol traffic. It provides: + +- `POST /activity/messages` (and the `POST /api/messages` alias) for inbound activities. + +### Usage patterns + +**Build the M365 stack (default)** — register handlers on the host's `agent_app`: + +```python +from azure.ai.agentserver.activity import ActivityAgentServerHost + +app = ActivityAgentServerHost() + +@app.agent_app.activity("message") +async def on_message(context, state): + await context.send_activity(f"Echo: {context.activity.text}") + +@app.agent_app.error +async def on_error(context, error): + await context.send_activity(f"Error: {error}") + +app.run() +``` + +**Build the M365 stack, with overrides** — same default path, but override any +components you want to control (the host builds the rest from the environment): + +```python +from microsoft_agents.hosting.core import MemoryStorage +from azure.ai.agentserver.activity import ActivityAgentServerHost + +# Override just the storage backend; connection manager / adapter / authorization +# / config are still built for you. Add digital_worker=True for the blueprint model. +app = ActivityAgentServerHost(storage=MemoryStorage()) +``` + +**Inject a pre-built `AgentApplication`** — host an M365 `AgentApplication` you built yourself: + +```python +from azure.ai.agentserver.activity import ActivityAgentServerHost + +# agent_app: a fully-built microsoft_agents AgentApplication (with an adapter) +app = ActivityAgentServerHost.from_agent_application(agent_app) +app.run() +``` + +**Custom handler** — you own the request pipeline (the M365 SDK is not initialized): + +```python +from starlette.responses import Response + +from azure.ai.agentserver.activity import ActivityAgentServerHost + +async def handle(request): + activity = request.state.activity # parsed dict + # Custom processing... + return Response(status_code=202) + +app = ActivityAgentServerHost.from_request_handler(handle) +app.run() +``` + +### Request header contract + +`POST /activity/messages` consumes: + +- `x-agent-session-id` (preferred session source) +- `x-agent-conversation-id` +- `x-agent-user-id` (per-user identity) and `x-agent-foundry-call-id` (per-request call ID, container protocol `2.0.0`) +- `traceparent`, `tracestate`, and `baggage` + +### Public API + +- `ActivityAgentServerHost` — the host class. Constructed directly, it builds the + M365 stack and acts as the underlying M365 `AgentApplication`: register handlers + and reach the full M365 surface (`activity`/`error`/`message`/`proactive`/`auth` + ...) directly on the host. Optional keyword overrides: `digital_worker`, + `storage`, `connection_manager`, `adapter`, `authorization`, `config`. +- `ActivityAgentServerHost.from_agent_application(agent_app)` — host a pre-built + M365 `AgentApplication` (the adapter is taken from `agent_app.adapter`). +- `ActivityAgentServerHost.from_request_handler(handler)` — host a custom async + request handler; the M365 SDK is not initialized. +- `get_hosted_agent_env(*, digital_worker=False)` — return a config mapping + (`os.environ` overlaid with the derived `CONNECTIONS__*` settings from the + Foundry-native `FOUNDRY_AGENT_*` env) **without mutating the environment**. The + default constructor derives this for you; call it yourself only when you build + the `MsalConnectionManager` (or a pre-built `AgentApplication`) manually — see + the `03-self-hosted-app` sample. +- `ActivityAgentServerHost.adapter` — the channel adapter for the underlying + `AgentApplication`. + +## Examples + +See the [samples directory](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/agentserver/azure-ai-agentserver-activity/samples) for runnable scenarios, ordered as a learning path: + +- `01-echo` — the simplest agent: the host builds the M365 stack and you register handlers directly on it to echo the user's message back. +- `02-custom-components` — override one or more M365 components (storage, connection manager, adapter, authorization, config) while the host builds the rest. +- `03-self-hosted-app` — build the M365 `AgentApplication` yourself and host it with `from_agent_application`. +- `04-custom-handler` — own the request pipeline with `from_request_handler` (the M365 SDK is not initialized). +- `05-multi-protocol` — compose the Activity and Invocations protocols on a single server. + +## Troubleshooting + +### `ImportError`: M365 Agents SDK not installed + +Constructing `ActivityAgentServerHost()` directly (or via `from_agent_application`) +requires the M365 Agents SDK. Install it: + +```bash +pip install microsoft-agents-hosting-core microsoft-agents-authentication-msal microsoft-agents-activity azure-identity +``` + +Alternatively, use `ActivityAgentServerHost.from_request_handler(...)`, which does +not initialize the M365 SDK. + +### `TypeError`: handler must be an async function + +`from_request_handler(...)` requires an `async def` handler +(`async def handle(request) -> Response`). A plain `def` is rejected. + +### `AttributeError` when accessing `agent_app` + +`app.agent_app` (and handler registration via `@app.agent_app.activity(...)` / +`@app.agent_app.error`) is only available when the host builds or is given an M365 +`AgentApplication` (the default constructor or `from_agent_application`). A host +created with `from_request_handler(...)` does not expose the M365 surface. + +## Next steps + +- Review the [Azure AI Hosted Agent documentation](https://aka.ms/azsdk/foundry/hosted-agents) +- Explore the [Activity Protocol specification](https://aka.ms/azsdk/foundry/activity-protocol) +- Check the [`azure-ai-agentserver-core`](https://pypi.org/project/azure-ai-agentserver-core/) package for base host functionality +- Learn about deployment patterns in [Foundry quickstarts](https://aka.ms/azsdk/foundry/quickstarts) + +## 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.microsoft.com](https://cla.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. diff --git a/sdk/agentserver/azure-ai-agentserver-activity/api.md b/sdk/agentserver/azure-ai-agentserver-activity/api.md new file mode 100644 index 000000000000..136d8b3a1f97 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/api.md @@ -0,0 +1,99 @@ +```py +namespace azure.ai.agentserver.activity + + def azure.ai.agentserver.activity.get_hosted_agent_env(*, digital_worker: bool = False) -> dict[str, str]: ... + + + class azure.ai.agentserver.activity.ActivityAgentServerHost(AgentServerHost): + property adapter: Optional[HttpAdapterBase] # Read-only + property agent_app: AgentApplication # Read-only + property connection_config: Optional[Mapping[str, str]] # Read-only + property routes: list[BaseRoute] # Read-only + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send + ) -> None: ... + + def __init__( + self, + *, + adapter: Optional[HttpAdapterBase] = ..., + agent_app: Optional[AgentApplication] = ..., + authorization: Optional[Authorization] = ..., + connection_config: Optional[Mapping[str, str]] = ..., + connection_manager: Optional[MsalConnectionManager] = ..., + digital_worker: bool = False, + request_handler: Optional[Callable[[Request], Awaitable[Response]]] = ..., + storage: Optional[Storage] = ..., + **kwargs: Any + ) -> None: ... + + def add_exception_handler( + self, + exc_class_or_status_code: int | type[Exception], + handler: ExceptionHandler + ) -> None: ... + + def add_middleware( + self, + middleware_class: _MiddlewareFactory[P], + *args: args, + **kwargs: kwargs + ) -> None: ... + + def add_route( + self, + path: str, + route: Callable[[Request], Awaitable[Response] | Response], + methods: list[str] | None = None, + name: str | None = None, + include_in_schema: bool = True + ) -> None: ... + + def build_middleware_stack(self) -> ASGIApp: ... + + def host( + self, + host: str, + app: ASGIApp, + name: str | None = None + ) -> None: ... + + def mount( + self, + path: str, + app: ASGIApp, + name: str | None = None + ) -> None: ... + + def register_server_version(self, version_segment: str) -> None: ... + + def run( + self, + host: str = "0.0.0.0", + port: Optional[int] = None + ) -> None: ... + + async def run_async( + self, + host: str = "0.0.0.0", + port: Optional[int] = None + ) -> None: ... + + def shutdown_handler(self, fn: Callable[[], Awaitable[None]]) -> Callable[[], Awaitable[None]]: ... + + @staticmethod + async def sse_keepalive_stream(iterator: AsyncIterable[_Content], interval: int) -> AsyncIterator[_Content]: ... + + def url_path_for( + self, + name: str, + /, + **path_params: Any + ) -> URLPath: ... + + +``` \ No newline at end of file diff --git a/sdk/agentserver/azure-ai-agentserver-activity/api.metadata.yml b/sdk/agentserver/azure-ai-agentserver-activity/api.metadata.yml new file mode 100644 index 000000000000..c7b7fe15edfa --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/api.metadata.yml @@ -0,0 +1,3 @@ +apiMdSha256: f24092853feea2b3351388b925ce0d4363ef8a6b14249bb48e6c065884c1d5dd +parserVersion: 0.3.28 +pythonVersion: 3.12.10 diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/__init__.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/__init__.py new file mode 100644 index 000000000000..8db66d3d0f0f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/__init__.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/__init__.py new file mode 100644 index 000000000000..8db66d3d0f0f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/__init__.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/__init__.py new file mode 100644 index 000000000000..8db66d3d0f0f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/__init__.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/__init__.py new file mode 100644 index 000000000000..f043c5984021 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/__init__.py @@ -0,0 +1,65 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Activity protocol host for Azure AI Hosted Agents. + +This package provides an activity protocol host as a subclass of +:class:`~azure.ai.agentserver.core.AgentServerHost`. + +Default usage — the M365 Agents SDK is initialized during construction and the +built ``AgentApplication`` is exposed as the ``agent_app`` property (register +handlers on it and reach the full M365 surface):: + + from azure.ai.agentserver.activity import ActivityAgentServerHost + + host = ActivityAgentServerHost() + app = host.agent_app + + @app.activity("message") + async def on_message(context, state): + await context.send_activity(f"Echo: {context.activity.text}") + + host.run() + +The default path also accepts optional overrides — pass any of ``storage`` / +``connection_manager`` / ``adapter`` / ``authorization`` / ``connection_config`` +(or ``digital_worker=True``) and the host builds the rest from the environment:: + + from microsoft_agents.hosting.core import MemoryStorage + + # Override just the storage backend; the host builds the rest. + host = ActivityAgentServerHost(storage=MemoryStorage()) + app = host.agent_app + +Injected ``AgentApplication`` usage — host a pre-built M365 ``AgentApplication`` +you constructed yourself (the adapter is taken from ``agent_app.adapter``):: + + from azure.ai.agentserver.activity import ActivityAgentServerHost + + # agent_app: a fully-built microsoft_agents AgentApplication (with an adapter) + host = ActivityAgentServerHost(agent_app=agent_app) + host.run() + +Custom handler usage — the M365 SDK is not initialized; you own the pipeline:: + + from starlette.responses import Response + + from azure.ai.agentserver.activity import ActivityAgentServerHost + + async def handle(request): + activity = request.state.activity + # Custom processing... + return Response(status_code=202) + + host = ActivityAgentServerHost(request_handler=handle) + host.run() +""" + +__path__ = __import__("pkgutil").extend_path(__path__, __name__) + +from ._activity import ActivityAgentServerHost +from ._config import get_hosted_agent_env +from ._version import VERSION + +__all__ = ["ActivityAgentServerHost", "get_hosted_agent_env"] +__version__ = VERSION diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_activity.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_activity.py new file mode 100644 index 000000000000..dfb4b1ae2fbe --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_activity.py @@ -0,0 +1,810 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Activity protocol host for Azure AI Hosted Agents. + +Home of :class:`ActivityAgentServerHost` — an +:class:`~azure.ai.agentserver.core.AgentServerHost` subclass that adds the +activity protocol endpoint (``POST /activity/messages``). + +This module wires the endpoint end to end: request parsing and validation, +session / correlation resolution, log-record enrichment, OpenTelemetry baggage, +error-source classification, and dispatch to one of the three construction modes +(built M365 stack, injected ``AgentApplication``, or custom request handler). The +M365 stack build lives in :mod:`._m365_bridge` and the Starlette turn adapter in +:mod:`._cloud_adapter`. +""" + +from __future__ import annotations + +import inspect +import logging +import re +import uuid +from collections.abc import Awaitable, Callable, Mapping +from contextvars import Token +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Optional + +from opentelemetry import baggage as _otel_baggage +from opentelemetry import context as _otel_context +from opentelemetry import trace as _otel_trace +from opentelemetry.context import Context +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Route + +from azure.ai.agentserver.core import ( + AgentServerHost, + FoundryAgentRequestContext, + create_error_response, + detach_context, + end_span, + flush_spans, + get_request_context, + reset_request_context, + set_current_span, + set_request_context, +) +from azure.ai.agentserver.core._platform_headers import ( # pylint: disable=import-error,no-name-in-module + ERROR_DETAIL, + ERROR_SOURCE, + FOUNDRY_CALL_ID, + MAX_ERROR_DETAIL_LENGTH, + PLATFORM_ERROR_TAG, + USER_ID, +) + +from ._config import get_hosted_agent_env +from ._constants import ( + MAX_ID_LENGTH, + VALID_ID_PATTERN, + ActivityConstants, + ActivityFields, + BaggageKeys, + ConnectionSettings, + ErrorCode, + ErrorSource, + LogRecordFields, + SpanAttributes, +) + +if TYPE_CHECKING: # pragma: no cover - type-only imports (M365 SDK is optional) + from microsoft_agents.hosting.core import ( + AgentApplication, + Authorization, + HttpAdapterBase, + Storage, + ) + from microsoft_agents.authentication.msal import MsalConnectionManager + +logger = logging.getLogger("azure.ai.agentserver.activity.host") + +_VALID_ID_RE = re.compile(VALID_ID_PATTERN) + + +def _sanitize_id(value: str, fallback: str) -> str: + """Validate a user-provided ID string. + + Returns *value* unchanged when it passes validation, otherwise *fallback*. + Prevents excessively long or malformed IDs from propagating into headers, + span attributes, and log messages. + + :param value: The raw ID from a header, query parameter, or payload. + :type value: str + :param fallback: A safe fallback value (typically a generated UUID). + :type fallback: str + :return: The validated ID or the fallback. + :rtype: str + """ + if not value or len(value) > MAX_ID_LENGTH or not _VALID_ID_RE.match(value): + return fallback + return value + + +def _enrich_record(record: logging.LogRecord) -> None: + """Populate activity scope fields on a log record from the request context. + + :param record: The log record to enrich. + :type record: logging.LogRecord + """ + ctx = get_request_context() + if not hasattr(record, LogRecordFields.SESSION_ID): + setattr(record, LogRecordFields.SESSION_ID, ctx.session_id or "") + if not hasattr(record, LogRecordFields.USER_ID): + setattr(record, LogRecordFields.USER_ID, ctx.user_id or "") + if not hasattr(record, LogRecordFields.CALL_ID): + setattr(record, LogRecordFields.CALL_ID, ctx.call_id or "") + if not hasattr(record, LogRecordFields.PROTOCOL): + setattr(record, LogRecordFields.PROTOCOL, ActivityConstants.PROTOCOL) + + +def _install_log_enrichment() -> None: + """Install a log-record factory that enriches records with scope fields.""" + base_factory = logging.getLogRecordFactory() + if getattr(base_factory, LogRecordFields.ENRICHER_FLAG, False): + return + + def factory(*args: object, **kwargs: object) -> logging.LogRecord: + record = base_factory(*args, **kwargs) + _enrich_record(record) + return record + + setattr(factory, LogRecordFields.ENRICHER_FLAG, True) + logging.setLogRecordFactory(factory) + + +def _apply_error_source_headers( + headers: dict[str, str], + error_source: str, + error_detail: Optional[str] = None, +) -> dict[str, str]: + """Return a new dict with error source classification headers merged in. + + :param headers: Base headers to merge into. + :type headers: dict[str, str] + :param error_source: The error source value (user/platform/upstream). + :type error_source: str + :param error_detail: Optional detail string for platform errors. + :type error_detail: str or None + :return: A new dict containing the original headers plus error source headers. + :rtype: dict[str, str] + """ + merged = {**headers, ERROR_SOURCE: error_source} + if error_detail: + merged[ERROR_DETAIL] = error_detail + return merged + + +def _classify_error(exc: BaseException) -> tuple[str, Optional[str]]: + """Classify an exception: platform-tagged -> (platform, detail), else -> (upstream, None). + + :param exc: The exception to classify. + :type exc: BaseException + :return: A tuple of (source, detail) where source is 'platform' or 'upstream'. + :rtype: tuple[str, Optional[str]] + """ + if getattr(exc, PLATFORM_ERROR_TAG, False) is True: + detail = f"{type(exc).__name__}: {exc}" + if len(detail) > MAX_ERROR_DETAIL_LENGTH: + suffix = "...[truncated]" + detail = detail[: MAX_ERROR_DETAIL_LENGTH - len(suffix)] + suffix + return ErrorSource.PLATFORM, detail + return ErrorSource.UPSTREAM, None + + +@dataclass(frozen=True) +class _RequestMeta: + """Activity fields extracted for state, logging, and correlation. + + A typed, immutable view of the fields the host reads from an inbound activity + (rather than a loose ``dict``), so downstream logging / correlation access + named attributes. + + :ivar activity_id: The sanitized activity id (a generated UUID when absent). + :vartype activity_id: str + :ivar conversation_id: The conversation id (falls back to the request header). + :vartype conversation_id: str + :ivar type: The activity type. + :vartype type: str + :ivar from_id: The ``from`` account id. + :vartype from_id: str + :ivar recipient_id: The ``recipient`` account id. + :vartype recipient_id: str + :ivar channel_id: The channel id. + :vartype channel_id: str + :ivar service_url: The service URL. + :vartype service_url: str + :ivar locale: The activity locale. + :vartype locale: str + :ivar x_request_id: The inbound ``x-request-id`` correlation header. + :vartype x_request_id: str + """ + + activity_id: str + conversation_id: str + type: str + from_id: str + recipient_id: str + channel_id: str + service_url: str + locale: str + x_request_id: str + + +class ActivityAgentServerHost(AgentServerHost): + """Activity protocol host for Azure AI Hosted Agents. + + A :class:`~azure.ai.agentserver.core.AgentServerHost` subclass that adds + the activity protocol endpoint at ``POST /activity/messages``. A single + constructor selects one of three mutually-exclusive modes from the arguments + you pass (an invalid combination raises ``ValueError``): + + 1. **Build the M365 stack (default).** Pass neither ``agent_app`` nor + ``request_handler``. The M365 Agents SDK is initialized eagerly from the + environment (optionally overriding ``storage`` / ``connection_manager`` / + ``adapter`` / ``authorization`` / ``connection_config``). The built + ``AgentApplication`` is exposed as the :attr:`agent_app` property — + capture it (``app = host.agent_app``) and register handlers with + ``@app.activity(...)`` / ``@app.error``, reaching the rest of the M365 + surface (``message`` / ``proactive`` / ``auth`` ...) the same way. + 2. **Inject a pre-built ``AgentApplication``.** Pass ``agent_app=`` to + host an ``AgentApplication`` you built yourself, as-is. It is exposed as + :attr:`agent_app`. + 3. **Custom request handler.** Pass ``request_handler=`` to own the + request pipeline entirely; the M365 SDK is not initialized, :attr:`agent_app` + is unavailable, and the handler receives the raw Starlette ``Request`` + with ``request.state.activity`` set to the parsed dict. + + See the package overview (:mod:`azure.ai.agentserver.activity`) for runnable + usage examples of all three construction modes. + + :keyword agent_app: A pre-built M365 ``AgentApplication`` to host as-is + (mode 2). Mutually exclusive with ``request_handler`` and with the + M365-build overrides. Defaults to ``None`` (build the stack). + :paramtype agent_app: Optional[~microsoft_agents.hosting.core.AgentApplication] + :keyword request_handler: A custom ``async def handler(request) -> Response`` + that owns the pipeline (mode 3). Mutually exclusive with ``agent_app`` + and the M365-build overrides. Defaults to ``None``. + :paramtype request_handler: + Optional[Callable[[~starlette.requests.Request], + Awaitable[~starlette.responses.Response]]] + :keyword digital_worker: Selects the outbound-auth model. ``False`` (the + default) is the **simple** model: the agent *instance* identity mints + the Bot Connector token directly. ``True`` is the **digital-worker** + model: the *blueprint* identity with the federated-identity (FMI) + token exchange. + :paramtype digital_worker: bool + :keyword storage: Optional storage backend for the built M365 stack. + :paramtype storage: Optional[~microsoft_agents.hosting.core.Storage] + :keyword connection_manager: Optional M365 connection manager. + :paramtype connection_manager: + Optional[~microsoft_agents.authentication.msal.MsalConnectionManager] + :keyword adapter: Optional channel adapter. + :paramtype adapter: Optional[~microsoft_agents.hosting.core.HttpAdapterBase] + :keyword authorization: Optional M365 ``Authorization`` instance. + :paramtype authorization: Optional[~microsoft_agents.hosting.core.Authorization] + :keyword connection_config: Optional connection config (the M365 + ``CONNECTIONS__*`` mapping) for the built M365 stack. When supplied it + also drives the outbound-auth client id, and it is exposed after + construction as :attr:`connection_config`. + :paramtype connection_config: Optional[Mapping[str, str]] + """ + + def __init__( + self, + *, + agent_app: Optional[AgentApplication] = None, + request_handler: Optional[Callable[[Request], Awaitable[Response]]] = None, + digital_worker: bool = False, + storage: Optional[Storage] = None, + connection_manager: Optional[MsalConnectionManager] = None, + adapter: Optional[HttpAdapterBase] = None, + authorization: Optional[Authorization] = None, + connection_config: Optional[Mapping[str, str]] = None, + **kwargs: Any, + ) -> None: + """Initialize the host, selecting the construction mode from the arguments. + + The mode is inferred from which of ``agent_app`` / ``request_handler`` is + supplied (see the class docstring); passing an invalid combination raises + ``ValueError`` so a bad configuration cannot be expressed. + + :keyword agent_app: A pre-built ``AgentApplication`` to host as-is, or + ``None`` to build the M365 stack. Mutually exclusive with + ``request_handler`` and the M365-build overrides. + :paramtype agent_app: Optional[~microsoft_agents.hosting.core.AgentApplication] + :keyword request_handler: A custom async request handler that owns the + pipeline, or ``None``. Mutually exclusive with ``agent_app`` and the + M365-build overrides. + :paramtype request_handler: + Optional[Callable[[~starlette.requests.Request], + Awaitable[~starlette.responses.Response]]] + :keyword digital_worker: Selects the outbound-auth model (see the class + docstring). Defaults to the simple model. + :paramtype digital_worker: bool + :keyword storage: Optional storage backend for the built M365 stack. + :paramtype storage: Optional[~microsoft_agents.hosting.core.Storage] + :keyword connection_manager: Optional M365 connection manager. + :paramtype connection_manager: + Optional[~microsoft_agents.authentication.msal.MsalConnectionManager] + :keyword adapter: Optional channel adapter. + :paramtype adapter: Optional[~microsoft_agents.hosting.core.HttpAdapterBase] + :keyword authorization: Optional M365 ``Authorization`` instance. + :paramtype authorization: Optional[~microsoft_agents.hosting.core.Authorization] + :keyword connection_config: Optional connection config (the M365 + ``CONNECTIONS__*`` mapping) for the built M365 stack. When supplied it + also drives the outbound-auth client id, and it is exposed after + construction as :attr:`connection_config`. + :paramtype connection_config: Optional[Mapping[str, str]] + :raises ValueError: If ``agent_app`` and ``request_handler`` are both + supplied, or if either is combined with an M365-build override. + :return: None. + :rtype: None + """ + # Validate the mode selection up front so an invalid combination is + # rejected instead of silently ignoring the extra arguments. + if agent_app is not None and request_handler is not None: + raise ValueError("Pass either 'agent_app' or 'request_handler', not both.") + build_overrides = { + "storage": storage, + "connection_manager": connection_manager, + "adapter": adapter, + "authorization": authorization, + "connection_config": connection_config, + } + if request_handler is not None: + supplied = [name for name, value in build_overrides.items() if value is not None] + if supplied: + raise ValueError( + "request_handler mode does not build the M365 stack; remove the " + f"M365-build override(s): {', '.join(supplied)}." + ) + elif agent_app is not None: + supplied = [name for name, value in build_overrides.items() if value is not None] + if supplied: + raise ValueError( + "agent_app is hosted as-is; the M365-build override(s) " + f"{', '.join(supplied)} would be ignored. Remove them, or drop " + "agent_app to let the host build the stack." + ) + + self._digital_worker: bool = bool(digital_worker) + self._agent_app: Optional[AgentApplication] = None + self._adapter: Optional[HttpAdapterBase] = None + self._handler: Optional[Callable[[Request], Awaitable[Response]]] = None + self._connection_config: Optional[Mapping[str, str]] = None + + # Register the activity routes and initialize the base host first, so the + # core-resolved configuration (``self.config``, resolved once) is available + # to the M365 build below. + activity_routes: list[Route] = self._build_activity_routes() + existing_routes: list[Route] = list(kwargs.pop("routes", None) or []) + super().__init__(routes=existing_routes + activity_routes, **kwargs) + + if request_handler is not None: + # Custom-handler mode: the caller owns the pipeline; the M365 SDK is + # not initialized and agent_app stays unavailable. + if not inspect.iscoroutinefunction(request_handler): + raise TypeError( + f"request_handler must be an async function, got " + f"{type(request_handler).__name__}. Use 'async def' to define it." + ) + self._handler = request_handler + else: + # Build the M365 stack (default) or host the injected agent_app as-is. + self._build_m365_stack( + agent_app=agent_app, + storage=storage, + connection_manager=connection_manager, + adapter=adapter, + authorization=authorization, + connection_config=connection_config, + ) + + # Install logging enrichment for this host. The core observability stack + # promotes session_id / conversation_id baggage onto spans and logs. + _install_log_enrichment() + + mode = "custom-handler" if request_handler is not None else "m365" + logger.info( + "ActivityAgentServerHost initialized | mode=%s | digital_worker=%s", + mode, + self._digital_worker, + ) + + def _build_m365_stack( + self, + *, + agent_app: Optional[AgentApplication], + storage: Optional[Storage], + connection_manager: Optional[MsalConnectionManager], + adapter: Optional[HttpAdapterBase], + authorization: Optional[Authorization], + connection_config: Optional[Mapping[str, str]], + ) -> None: + """Build the M365 stack (default) or host the injected ``agent_app`` as-is. + + Resolves the connection config ONCE (caller-supplied ``connection_config`` + or the values derived from the Foundry-native identity), then reads the + outbound-auth inputs from that single mapping so per-request auth never + re-reads process-global state. Sets :attr:`_connection_config`, + :attr:`_agent_app`, :attr:`_adapter` and :attr:`_handler`. + + :keyword agent_app: The pre-built ``AgentApplication`` to inject, or ``None``. + :paramtype agent_app: Optional[~microsoft_agents.hosting.core.AgentApplication] + :keyword storage: Optional storage backend for the built M365 stack. + :paramtype storage: Optional[~microsoft_agents.hosting.core.Storage] + :keyword connection_manager: Optional M365 connection manager. + :paramtype connection_manager: + Optional[~microsoft_agents.authentication.msal.MsalConnectionManager] + :keyword adapter: Optional channel adapter. + :paramtype adapter: Optional[~microsoft_agents.hosting.core.HttpAdapterBase] + :keyword authorization: Optional M365 ``Authorization`` instance. + :paramtype authorization: Optional[~microsoft_agents.hosting.core.Authorization] + :keyword connection_config: Optional connection config for the built stack. + :paramtype connection_config: Optional[Mapping[str, str]] + :return: None. + :rtype: None + """ + from ._m365_bridge import build_bridge_handler, build_m365_app + + self._connection_config = ( + connection_config + if connection_config is not None + else get_hosted_agent_env(digital_worker=self._digital_worker) + ) + bot_app_id = self._connection_config.get(ConnectionSettings.CLIENT_ID, "").strip() + self._agent_app, self._adapter = build_m365_app( + digital_worker=self._digital_worker, + connection_config=self._connection_config, + storage=self._resolve_storage(storage) if agent_app is None else storage, + connection_manager=connection_manager, + adapter=adapter, + authorization=authorization, + agent_app=agent_app, + ) + self._handler = build_bridge_handler( + self._agent_app, + self._adapter, + digital_worker=self._digital_worker, + is_hosted=self.config.is_hosted, + bot_app_id=bot_app_id, + ) + + def _build_activity_routes(self) -> list[Route]: + """Build the activity protocol routes registered on the host. + + Override in a subclass to customize or extend the activity endpoints + (for example, to add an alternate path or change the HTTP methods). + + :return: The routes to register for the activity protocol endpoint. + :rtype: list[~starlette.routing.Route] + """ + return [ + Route( + ActivityConstants.ACTIVITY_MESSAGES_PATH, + self._create_activity_endpoint, + methods=["POST"], + name=ActivityConstants.ACTIVITY_ROUTE_NAME, + ), + Route( + ActivityConstants.API_MESSAGES_PATH, + self._create_activity_endpoint, + methods=["POST"], + name=ActivityConstants.API_MESSAGES_ROUTE_NAME, + ), + ] + + def _resolve_storage(self, storage: Optional[Storage]) -> Storage: + """Resolve the storage backend for the built M365 stack. + + Resolution order: the caller-supplied ``storage`` if provided, otherwise + an in-memory store suitable for local testing. Override in a subclass to + plug a durable backend (for example a hosted persistent store). + + :param storage: The caller-supplied storage backend, or ``None``. + :type storage: Optional[~microsoft_agents.hosting.core.Storage] + :return: The storage backend to use. + :rtype: ~microsoft_agents.hosting.core.Storage + """ + if storage is not None: + return storage + # TODO: use a durable hosted store (FoundryStorage) when running in a + # Foundry-hosted container; MemoryStorage is the local-testing default. + # pylint: disable=import-error,no-name-in-module + from microsoft_agents.hosting.core import MemoryStorage + + return MemoryStorage() + + @property + def connection_config(self) -> Optional[Mapping[str, str]]: + """The resolved M365 ``CONNECTIONS__*`` mapping used to build the stack. + + Exposes the connection config the host resolved (the caller-supplied + ``connection_config`` keyword, or the values derived from the + Foundry-native identity) so callers can inspect the exact settings the + M365 stack was built from. Distinct from :attr:`config` (the core Foundry + ``AgentConfig``). ``None`` when the host was created with a custom + ``request_handler`` (the M365 stack is not built in that mode). + + :return: The resolved connection config, or ``None`` in custom-handler mode. + :rtype: Optional[Mapping[str, str]] + """ + return self._connection_config + + @property + def agent_app(self) -> AgentApplication: + """The underlying M365 ``AgentApplication`` for handler registration. + + Capture it (``app = host.agent_app``) and register handlers on it, for + example ``@app.activity("message")`` / ``@app.error``, or use it + standalone. + + :return: The hosted ``AgentApplication``. + :rtype: ~microsoft_agents.hosting.core.AgentApplication + :raises AttributeError: When the host was created with a custom + ``request_handler`` (custom-handler mode), where no + ``AgentApplication`` is initialized. + """ + agent_app = self._agent_app + if agent_app is None: + raise AttributeError( + "The M365 AgentApplication is not initialized because the host was " + "created with a custom request_handler. Construct the host without " + "request_handler (optionally passing agent_app=) to use agent_app." + ) + return agent_app + + @property + def adapter(self) -> Optional[HttpAdapterBase]: + """The channel adapter for the underlying ``AgentApplication``. + + :return: The adapter, or ``None`` when the host was created with a custom + ``request_handler``. + :rtype: Optional[~microsoft_agents.hosting.core.HttpAdapterBase] + """ + return self._adapter + + def _resolve_session_id(self, request: Request) -> str: + query_session_id = request.query_params.get(ActivityConstants.SESSION_ID_QUERY_PARAM) + if query_session_id and query_session_id.strip(): + return query_session_id.strip() + + header_id = request.headers.get(ActivityConstants.SESSION_ID_HEADER) + if header_id and header_id.strip(): + return header_id.strip() + + if self.config.session_id and self.config.session_id.strip(): + return self.config.session_id.strip() + + return str(uuid.uuid4()) + + def _add_required_response_headers(self, response: Response, session_id: str) -> None: + response.headers[ActivityConstants.SESSION_ID_HEADER] = session_id + + def _build_bad_request(self, message: str, session_id: str, reason: str) -> Response: + """Build a 400 invalid_request response and stamp the session header. + + :param message: The human-readable error message for the response body. + :type message: str + :param session_id: The resolved session ID to stamp on the response. + :type session_id: str + :param reason: A short machine-readable reason code for the rejection log. + :type reason: str + :return: A 400 invalid_request response with the session header set. + :rtype: ~starlette.responses.Response + """ + logger.warning("Activity request rejected | reason=%s | session_id=%s", reason, session_id) + response = create_error_response( + ErrorCode.INVALID_REQUEST, + message, + status_code=400, + headers=_apply_error_source_headers({}, ErrorSource.UPSTREAM), + ) + self._add_required_response_headers(response, session_id) + return response + + @staticmethod + def _extract_request_meta(request: Request, payload: Mapping[str, object]) -> _RequestMeta: + """Extract the activity fields used for state, logging, and correlation. + + :param request: The inbound request (for the conversation fallback header). + :type request: ~starlette.requests.Request + :param payload: The parsed activity dict. + :type payload: Mapping[str, object] + :return: The typed activity metadata. + :rtype: _RequestMeta + """ + + def as_str(value: object) -> str: + return value if isinstance(value, str) else "" + + def nested_id(value: object) -> str: + return as_str(value.get(ActivityFields.ID)) if isinstance(value, dict) else "" + + activity_id = _sanitize_id(as_str(payload.get(ActivityFields.ID)), str(uuid.uuid4())) + + conversation_id = nested_id(payload.get(ActivityFields.CONVERSATION)).strip() + if not conversation_id: + conversation_id = request.headers.get(ActivityConstants.CONVERSATION_ID_HEADER, "").strip() + + return _RequestMeta( + activity_id=activity_id, + conversation_id=conversation_id, + type=as_str(payload.get(ActivityFields.TYPE)), + from_id=nested_id(payload.get(ActivityFields.FROM)), + recipient_id=nested_id(payload.get(ActivityFields.RECIPIENT)), + channel_id=as_str(payload.get(ActivityFields.CHANNEL_ID)), + service_url=as_str(payload.get(ActivityFields.SERVICE_URL)), + locale=as_str(payload.get(ActivityFields.LOCALE)), + x_request_id=request.headers.get(ActivityConstants.REQUEST_ID_HEADER, "").strip(), + ) + + @staticmethod + def _set_correlation_baggage(session_id: str, conversation_id: str) -> Token[Context]: + """Attach the correlation baggage keys the core stack promotes onto spans/logs. + + :param session_id: The resolved session ID. + :type session_id: str + :param conversation_id: The resolved conversation ID (may be empty). + :type conversation_id: str + :return: The context token to detach when the turn completes. + :rtype: ~contextvars.Token + """ + ctx = _otel_baggage.set_baggage(BaggageKeys.SESSION_ID, session_id or "", context=_otel_context.get_current()) + if conversation_id: + ctx = _otel_baggage.set_baggage(BaggageKeys.CONVERSATION_ID, conversation_id, context=ctx) + return _otel_context.attach(ctx) + + def _build_invoke_span_attrs(self, activity_id: str, conversation_id: str, session_id: str) -> dict[str, str]: + """Build the attributes for the per-turn ``invoke_agent`` span. + + The agent name / version / id and the project id are set here directly + so they are guaranteed to be present on this one span, together with the + per-turn id (the activity id). That combination is what makes the turn + show up as a row in the trace list. Only values that are actually + available are added, so a local run without the platform environment + variables does not emit blank attributes. + + :param activity_id: The sanitized activity ID, used as the per-turn id. + :type activity_id: str + :param conversation_id: The resolved conversation ID (may be empty). + :type conversation_id: str + :param session_id: The resolved session ID. + :type session_id: str + :return: A mapping of span attribute keys to values. + :rtype: dict[str, str] + """ + attrs: dict[str, str] = { + SpanAttributes.GEN_AI_OPERATION_NAME: SpanAttributes.OPERATION_NAME_VALUE, + SpanAttributes.GEN_AI_SYSTEM: SpanAttributes.GEN_AI_SYSTEM_VALUE, + } + if activity_id: + attrs[SpanAttributes.RESPONSE_ID] = activity_id + if self.config.agent_name: + attrs[SpanAttributes.GEN_AI_AGENT_NAME] = self.config.agent_name + if self.config.agent_version: + attrs[SpanAttributes.GEN_AI_AGENT_VERSION] = self.config.agent_version + if self.config.agent_id: + attrs[SpanAttributes.GEN_AI_AGENT_ID] = self.config.agent_id + if self.config.project_id: + attrs[SpanAttributes.FOUNDRY_PROJECT_ID] = self.config.project_id + if conversation_id: + attrs[SpanAttributes.GEN_AI_CONVERSATION_ID] = conversation_id + if session_id: + attrs[SpanAttributes.SESSION_ID] = session_id + return attrs + + async def _run_handler(self, request: Request, activity_id: str, conversation_id: str, session_id: str) -> Response: + """Invoke the registered handler and classify any failure into a 500. + + :param request: The inbound request. + :type request: Request + :param activity_id: The sanitized activity ID. + :type activity_id: str + :param conversation_id: The resolved conversation ID. + :type conversation_id: str + :param session_id: The resolved session ID. + :type session_id: str + :return: The handler's response, or a classified 500 error response. + :rtype: Response + """ + tracer = _otel_trace.get_tracer("azure.ai.agentserver.activity") + span = tracer.start_span( + SpanAttributes.SPAN_NAME, + attributes=self._build_invoke_span_attrs(activity_id, conversation_id, session_id), + ) + span_token = set_current_span(span) + error: Optional[BaseException] = None + try: + if self._handler is None: + raise NotImplementedError( + "No activity handler registered. Register handlers on the" + " agent app (app = host.agent_app; @app.activity(...)), or" + " create the host with ActivityAgentServerHost(request_handler=fn)." + ) + response = await self._handler(request) + response.headers[ActivityConstants.ACTIVITY_ID_HEADER] = activity_id + self._add_required_response_headers(response, session_id) + logger.info( + "Activity response sent | status_code=%s | activity_id=%s | conversation_id=%s | session_id=%s", + getattr(response, "status_code", 0), + activity_id, + conversation_id, + session_id, + ) + return response + except Exception as exc: # pylint: disable=broad-exception-caught + error = exc + error_source, error_detail = _classify_error(exc) + logger.error( + "Activity request failed | activity_id=%s | conversation_id=%s | " + "session_id=%s | error_source=%s | error=%s", + activity_id, + conversation_id, + session_id, + error_source, + exc, + exc_info=True, + ) + response = create_error_response( + ErrorCode.INTERNAL_ERROR, + "Internal server error", + status_code=500, + headers=_apply_error_source_headers( + {ActivityConstants.ACTIVITY_ID_HEADER: activity_id}, + error_source, + error_detail, + ), + ) + self._add_required_response_headers(response, session_id) + return response + finally: + if span_token is not None: + detach_context(span_token) + end_span(span, exc=error) + flush_spans() + + async def _create_activity_endpoint(self, request: Request) -> Response: + """Handle inbound POST to ``/activity/messages``. + + :param request: The inbound HTTP request. + :type request: Request + :return: The HTTP response. + :rtype: Response + """ + inbound_user_id = request.headers.get(USER_ID, "") + inbound_call_id = request.headers.get(FOUNDRY_CALL_ID, "") + session_id = _sanitize_id(self._resolve_session_id(request), str(uuid.uuid4())) + + # Bind platform context so handler/tool code can forward the per-request + # call ID and user ID, and so log records carry the correlation fields. + ctx_token = set_request_context( + FoundryAgentRequestContext( + call_id=inbound_call_id or None, + user_id=inbound_user_id or None, + session_id=session_id, + ) + ) + baggage_token: Optional[Token[Context]] = None + try: + try: + payload = await request.json() + except Exception: # pylint: disable=broad-exception-caught + return self._build_bad_request("Request body must be valid JSON", session_id, "invalid_json") + if not isinstance(payload, dict): + return self._build_bad_request( + "Activity payload must be a JSON object", session_id, "non_object_payload" + ) + + meta = self._extract_request_meta(request, payload) + activity_id = meta.activity_id + conversation_id = meta.conversation_id + + request.state.activity = payload + logger.info( + "Activity request received | type=%s | activity_id=%s | conversation_id=%s | " + "session_id=%s | from=%s | recipient=%s | channelId=%s | serviceUrl=%s | " + "locale=%s | x_request_id=%s", + meta.type, + activity_id, + conversation_id, + session_id, + meta.from_id, + meta.recipient_id, + meta.channel_id, + meta.service_url, + meta.locale, + meta.x_request_id, + ) + + baggage_token = self._set_correlation_baggage(session_id, conversation_id) + return await self._run_handler(request, activity_id, conversation_id, session_id) + finally: + reset_request_context(ctx_token) + if baggage_token is not None: + try: + _otel_context.detach(baggage_token) + except ValueError: + pass diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_cloud_adapter.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_cloud_adapter.py new file mode 100644 index 000000000000..73ae16429651 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_cloud_adapter.py @@ -0,0 +1,112 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Starlette CloudAdapter for the Activity protocol host. + +A framework adapter modelled on the M365 Agents SDK per-framework adapters +small request adapter maps the Starlette request onto ``HttpRequestProtocol``, +and the CloudAdapter delegates a turn to the shared +:meth:`HttpAdapterBase.process_request` pipeline and converts the returned +framework-agnostic ``HttpResponse`` back to a Starlette response. + +This module contains *only* the framework adapter. Outbound-auth/claims and the +M365 stack build live in :mod:`._m365_bridge`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from ._constants import ErrorCode + +if TYPE_CHECKING: # pragma: no cover - type-only imports (M365 SDK is optional) + from microsoft_agents.hosting.core import Agent, ClaimsIdentity, HttpAdapterBase + from microsoft_agents.hosting.core.http import HttpResponse + + +class _StarletteRequestAdapter: + """Adapter to make a Starlette ``Request`` compatible with ``HttpRequestProtocol``.""" + + def __init__(self, request: Request) -> None: + self._request = request + + @property + def method(self) -> str: + return self._request.method + + @property + def headers(self) -> Any: + return self._request.headers + + async def json(self) -> dict: + return self._request.state.activity + + def get_claims_identity(self) -> Optional[ClaimsIdentity]: + return getattr(self._request.state, "claims_identity", None) + + def get_path_param(self, name: str) -> str: + return self._request.path_params.get(name, "") + + +class StarletteCloudAdapter: + """CloudAdapter for the Starlette web framework.""" + + def __init__(self, adapter: HttpAdapterBase) -> None: + """Initialize the CloudAdapter. + + :param adapter: The HTTP adapter providing the shared ``process_request`` + pipeline. + :type adapter: ~microsoft_agents.hosting.core.HttpAdapterBase + """ + self._adapter = adapter + + async def process(self, request: Request, agent: Agent) -> Response: + """Process a Starlette request. + + :param request: The Starlette request. + :type request: ~starlette.requests.Request + :param agent: The agent to handle the request. + :type agent: ~microsoft_agents.hosting.core.Agent + :return: The Starlette response. + :rtype: ~starlette.responses.Response + """ + # Adapt request to protocol + adapted_request = _StarletteRequestAdapter(request) + + # Process using the shared base implementation + http_response = await self._adapter.process_request(adapted_request, agent) + + # Convert HttpResponse to a Starlette response + return self._to_starlette_response(http_response) + + @staticmethod + def _to_starlette_response(http_response: HttpResponse) -> Response: + """Convert an M365 ``HttpResponse`` to a Starlette response. + + Errors (status >= 400) are re-wrapped into the Foundry error envelope + (``{"error": {"code", "message"}}``); success bodies pass through; any + headers on the ``HttpResponse`` are preserved. + + :param http_response: The framework-agnostic response from ``process_request``. + :type http_response: ~microsoft_agents.hosting.core.http.HttpResponse + :return: The equivalent Starlette response. + :rtype: ~starlette.responses.Response + """ + status_code = http_response.status_code + body = http_response.body + headers = dict(http_response.headers) if http_response.headers else None + + if status_code >= 400: + message = body.get("error") if isinstance(body, dict) else None + code = ErrorCode.INVALID_REQUEST if status_code == 400 else ErrorCode.INTERNAL_ERROR + return JSONResponse( + status_code=status_code, + content={"error": {"code": code, "message": message or "Request failed"}}, + headers=headers, + ) + if body is not None: + return JSONResponse(content=body, status_code=status_code, headers=headers) + return Response(status_code=status_code, headers=headers) diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_config.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_config.py new file mode 100644 index 000000000000..0bd0cdff7ad7 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_config.py @@ -0,0 +1,105 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Connection-config resolution for the M365 activity host. + +The connection config is resolved from the Foundry-native identity into a plain +``CONNECTIONS__*`` mapping (see :func:`get_hosted_agent_env`), and the +outbound-auth decision is a pure function of the resolved values +(:func:`use_anonymous_outbound`). Both are pure helpers: they read ``os.environ`` +at call time and never write to it, so the host can resolve the connection config +once at construction without mutating process-global state. +""" + +from __future__ import annotations + +import os + +from azure.ai.agentserver.core._config import ( # pylint: disable=import-error,no-name-in-module + resolve_agent_blueprint_id, + resolve_agent_tenant_id, +) + +from ._constants import ConnectionSettings, FoundryEnv, OutboundAuth + + +def get_hosted_agent_env(*, digital_worker: bool = False) -> dict[str, str]: + """Return a config mapping with the ``CONNECTIONS__*`` settings the M365 SDK needs. + + Builds and returns a **new** mapping — a copy of the process environment + overlaid with the ``CONNECTIONS__*`` connection settings derived from the + Foundry-native identity. It **never mutates** ``os.environ``; existing + explicit values are preserved (never overwritten). Pass the result straight + to ``load_configuration_from_env(...)``:: + + from azure.ai.agentserver.activity import get_hosted_agent_env + from microsoft_agents.activity import load_configuration_from_env + + env = get_hosted_agent_env() + agents_sdk_config = load_configuration_from_env(env) + + The identity source differs by auth model: + + * **Simple** (``digital_worker=False``, default): the instance client id + (``FOUNDRY_AGENT_INSTANCE_CLIENT_ID``), scoped to the Bot Framework. + * **Digital worker** (``digital_worker=True``): the blueprint client id + (via ``resolve_agent_blueprint_id``), scoped to the agentic resource. + + :keyword digital_worker: Selects the outbound-auth model to derive settings + for. Defaults to the simple model. + :paramtype digital_worker: bool + :return: A new mapping ``{**os.environ, **derived CONNECTIONS__*}``. + :rtype: dict[str, str] + """ + if digital_worker: + scope = OutboundAuth.AGENTIC_SCOPE + client_id = resolve_agent_blueprint_id().strip() + else: + scope = OutboundAuth.BOTFRAMEWORK_SCOPE + client_id = os.environ.get(FoundryEnv.INSTANCE_CLIENT_ID, "").strip() + + settings: dict[str, str] = dict(os.environ) + + def set_if_missing(name: str, value: str) -> None: + if value and not settings.get(name, "").strip(): + settings[name] = value + + set_if_missing(ConnectionSettings.AUTH_TYPE, ConnectionSettings.AUTH_TYPE_USER_MANAGED_IDENTITY) + set_if_missing(ConnectionSettings.SCOPE_0, scope) + set_if_missing(ConnectionSettings.MAP_0_SERVICE_URL, ConnectionSettings.MAP_SERVICE_URL_WILDCARD) + set_if_missing(ConnectionSettings.MAP_0_CONNECTION, ConnectionSettings.SERVICE_CONNECTION_NAME) + + tenant_id = resolve_agent_tenant_id().strip() + set_if_missing(ConnectionSettings.CLIENT_ID, client_id) + set_if_missing(ConnectionSettings.TENANT_ID, tenant_id) + set_if_missing( + ConnectionSettings.AUTHORITY, + ConnectionSettings.AUTHORITY_TEMPLATE.format(tenant_id=tenant_id) if tenant_id else "", + ) + return settings + + +def use_anonymous_outbound(*, digital_worker: bool, is_hosted: bool, bot_app_id: str) -> bool: + """Whether outbound replies should use anonymous (empty-token) auth. + + Pure decision over the resolved values (no process-global reads). The + digital-worker model never uses this path (its FMI patch supplies the + outbound token). For the simple model, a managed-identity Bot Connector token + can only be minted inside a hosted container; running outside a hosted + container **and** with no Bot Connector credential configured falls back to + anonymous claims so local runs can round-trip through local test channels + (for example the Microsoft 365 Agents Playground ``emulator`` channel) + without a Bot registration. + + :keyword digital_worker: The selected outbound-auth model. + :paramtype digital_worker: bool + :keyword is_hosted: Whether the agent runs inside a Foundry-hosted container. + :paramtype is_hosted: bool + :keyword bot_app_id: The resolved Bot Connector client id (empty when unset). + :paramtype bot_app_id: str + :return: ``True`` to use anonymous outbound auth, ``False`` otherwise. + :rtype: bool + """ + if digital_worker: + return False + return not is_hosted and not bot_app_id diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_constants.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_constants.py new file mode 100644 index 000000000000..b9e2ccefe50a --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_constants.py @@ -0,0 +1,181 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Activity protocol constants. + +All domain/magic strings used by the activity host live here: header names, +routing, M365 connection-manager configuration keys, outbound-auth scopes and +claims, the digital-worker MSAL patch, OpenTelemetry baggage keys, log-record +field names, error classification values, and inbound activity field names. + +Cross-cutting header names (for example the session ID header) are imported +from :mod:`azure.ai.agentserver.core._platform_headers`. +""" + +from azure.ai.agentserver.core._platform_headers import ( # pylint: disable=import-error,no-name-in-module + SESSION_ID as _SESSION_ID, +) + + +class ActivityConstants: + """Activity protocol header, routing, and query-parameter constants.""" + + PROTOCOL: str = "activity" + + # Request / response headers + ACTIVITY_ID_HEADER: str = "x-agent-activity-id" + SESSION_ID_HEADER: str = _SESSION_ID + CONVERSATION_ID_HEADER: str = "x-agent-conversation-id" + REQUEST_ID_HEADER: str = "x-request-id" + + # Query parameters + SESSION_ID_QUERY_PARAM: str = "agent_session_id" + + # Routing + ACTIVITY_MESSAGES_PATH: str = "/activity/messages" + API_MESSAGES_PATH: str = "/api/messages" + ACTIVITY_ROUTE_NAME: str = "create_activity" + API_MESSAGES_ROUTE_NAME: str = "create_activity_api_messages" + + +class FoundryEnv: + """Foundry-native environment variable names read directly by the activity host. + + Tenant ID and blueprint client ID are resolved via the core helpers + (``resolve_agent_tenant_id`` / ``resolve_agent_blueprint_id``). Only the + instance client ID is read here, because the core resolver + (``resolve_agent_id``) applies a ``name:version`` fallback that is not + appropriate for the outbound Bot Connector credential. + """ + + INSTANCE_CLIENT_ID: str = "FOUNDRY_AGENT_INSTANCE_CLIENT_ID" + + +class ConnectionSettings: + """M365 connection-manager configuration keys and values. + + The M365 SDK reads these ``CONNECTIONS__*`` / ``CONNECTIONSMAP__*`` keys from + its configuration mapping to build the Bot Connector connection manager. + """ + + # Keys + CLIENT_ID: str = "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID" + TENANT_ID: str = "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID" + AUTHORITY: str = "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHORITY" + AUTH_TYPE: str = "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHTYPE" + SCOPE_0: str = "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__SCOPES__0" + MAP_0_SERVICE_URL: str = "CONNECTIONSMAP__0__SERVICEURL" + MAP_0_CONNECTION: str = "CONNECTIONSMAP__0__CONNECTION" + + # Values + AUTH_TYPE_USER_MANAGED_IDENTITY: str = "UserManagedIdentity" + MAP_SERVICE_URL_WILDCARD: str = "*" + SERVICE_CONNECTION_NAME: str = "SERVICE_CONNECTION" + AUTHORITY_TEMPLATE: str = "https://login.microsoftonline.com/{tenant_id}" + + +class OutboundAuth: + """Outbound Bot Connector auth scopes and claims constants.""" + + # Token scopes per auth model + BOTFRAMEWORK_SCOPE: str = "https://api.botframework.com/.default" + AGENTIC_SCOPE: str = "5a807f24-c9de-44ee-a3a7-329e88a00ffc/.default" + + # Claim keys / authentication types + CLAIM_APP_ID: str = "appid" + CLAIM_AUDIENCE: str = "aud" + AUTH_TYPE_ANONYMOUS: str = "Anonymous" + AUTH_TYPE_BEARER: str = "Bearer" + + +class MsalPatch: + """Constants for the digital-worker MSAL federated-identity (FMI) patch.""" + + PATCH_FLAG: str = "_activity_sdk_msal_patched" + TOKEN_EXCHANGE_SCOPE: str = "api://AzureADTokenExchange/.default" + FMI_PATH_KEY: str = "fmi_path" + MSAL_CONFIGURATION_ATTR: str = "_msal_configuration" + MSAL_CLIENT_ID_ATTR: str = "CLIENT_ID" + + +class BaggageKeys: + """OpenTelemetry baggage keys promoted onto spans/logs by the core stack.""" + + SESSION_ID: str = "azure.ai.agentserver.session_id" + CONVERSATION_ID: str = "azure.ai.agentserver.conversation_id" + + +class SpanAttributes: + """Attribute keys and values for the per-turn ``invoke_agent`` span. + + Each turn is wrapped in one ``invoke_agent`` span so the turn shows up as a + single row in the trace list and becomes the parent of the spans the M365 + SDK creates. The agent name / version / id and the project id are set here + directly (not left to the shared stack) so they are guaranteed to be on this + one span together with the per-turn id — that combination is what makes the + turn appear in the trace list. + """ + + GEN_AI_OPERATION_NAME: str = "gen_ai.operation.name" + GEN_AI_SYSTEM: str = "gen_ai.system" + GEN_AI_AGENT_NAME: str = "gen_ai.agent.name" + GEN_AI_AGENT_VERSION: str = "gen_ai.agent.version" + GEN_AI_AGENT_ID: str = "gen_ai.agent.id" + GEN_AI_CONVERSATION_ID: str = "gen_ai.conversation.id" + FOUNDRY_PROJECT_ID: str = "microsoft.foundry.project.id" + SESSION_ID: str = "microsoft.session.id" + # Per-turn identifier used to give the turn its own row in the trace list. + # The activity turn has no response/invocation id of its own, so the + # activity id is used. + RESPONSE_ID: str = "azure.ai.agentserver.response_id" + + # Fixed attribute values. + GEN_AI_SYSTEM_VALUE: str = "activity" + OPERATION_NAME_VALUE: str = "invoke_agent" + SPAN_NAME: str = "invoke_agent" + + +class LogRecordFields: + """Log-record attribute names populated by the activity log enrichment.""" + + SESSION_ID: str = "SessionId" + USER_ID: str = "UserId" + CALL_ID: str = "CallId" + PROTOCOL: str = "Protocol" + ENRICHER_FLAG: str = "_activity_enricher" + + +class ErrorSource: + """Error-source classification values for the ``ERROR_SOURCE`` header.""" + + UPSTREAM: str = "upstream" + PLATFORM: str = "platform" + + +class ErrorCode: + """Error codes used in activity error responses.""" + + INVALID_REQUEST: str = "invalid_request" + INTERNAL_ERROR: str = "internal_error" + + +class ActivityFields: + """Inbound activity payload / turn field names and values.""" + + TYPE: str = "type" + ID: str = "id" + CONVERSATION: str = "conversation" + FROM: str = "from" + RECIPIENT: str = "recipient" + CHANNEL_ID: str = "channelId" + SERVICE_URL: str = "serviceUrl" + LOCALE: str = "locale" + UNKNOWN_TYPE: str = "unknown" + INVOKE_TYPE: str = "invoke" + DELIVERY_MODE_EXPECT_REPLIES: str = "expectReplies" + + +# ID validation (defense in depth): bound length and allowed characters for +# user-provided IDs before they reach headers, span attributes, and logs. +MAX_ID_LENGTH: int = 256 +VALID_ID_PATTERN: str = r"^[a-zA-Z0-9\-_.:]+$" diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_m365_bridge.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_m365_bridge.py new file mode 100644 index 000000000000..80def3802aa3 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_m365_bridge.py @@ -0,0 +1,324 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""M365 Agents SDK bridge for the Activity protocol host. + +Builds the M365 Agents SDK stack (``AgentApplication`` + adapter) from a resolved +``CONNECTIONS__*`` mapping or caller-supplied components, applies the MSAL auth +patch for the Foundry digital-worker model, synthesizes the per-turn outbound +claims, and binds the request handler that drives the Starlette +:class:`~._cloud_adapter.StarletteCloudAdapter`. + +Used internally by :class:`ActivityAgentServerHost` for its default (build the +stack) and injected-``agent_app`` construction paths. A host created with a +custom ``request_handler`` bypasses this module entirely. +""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable, Mapping +from typing import TYPE_CHECKING, Optional, cast + +from starlette.requests import Request +from starlette.responses import Response + +from ._config import use_anonymous_outbound +from ._constants import MsalPatch, OutboundAuth + +if TYPE_CHECKING: # pragma: no cover - type-only imports (M365 SDK is optional) + from microsoft_agents.hosting.core import ( + AgentApplication, + Authorization, + ClaimsIdentity, + HttpAdapterBase, + Storage, + ) + from microsoft_agents.authentication.msal import MsalConnectionManager + +logger = logging.getLogger("azure.ai.agentserver.activity.bridge") + +BridgeHandler = Callable[[Request], Awaitable[Response]] + + +def _apply_msal_patches() -> None: + """Apply the MSAL auth patch for the Foundry digital-worker (MAIB) model. + + When the auth type is ``UserManagedIdentity`` the stock ``MsalAuth`` uses + MSAL's ``ManagedIdentityClient``, which does not support ``fmi_path``. This + patch replaces ``get_agentic_application_token`` with a federated-identity + (FMI) exchange via azure-identity's ``ManagedIdentityCredential``. It is + idempotent (guarded by a patch flag) and a no-op when the M365 MSAL package + is not installed. + + :return: None. + :rtype: None + """ + try: + # pylint: disable=import-error,no-name-in-module + from microsoft_agents.authentication.msal.msal_auth import MsalAuth + except ImportError: + logger.debug("microsoft-agents-authentication-msal not installed; skipping MSAL patches") + return + + if getattr(MsalAuth, MsalPatch.PATCH_FLAG, False): + return + + async def get_token_via_managed_identity( + self: object, _tenant_id: str, agent_app_instance_id: str + ) -> Optional[str]: + # ``_tenant_id`` is part of the MsalAuth.get_agentic_application_token + # signature we are overriding (the SDK passes it positionally). The FMI + # exchange derives the tenant from the managed identity itself, so it is + # intentionally unused here. + from azure.identity.aio import ManagedIdentityCredential + + if not agent_app_instance_id: + # pylint: disable=import-error,no-name-in-module + from microsoft_agents.authentication.msal.errors import authentication_errors + + raise ValueError(str(authentication_errors.AgentApplicationInstanceIdRequired)) + + msal_configuration = getattr(self, MsalPatch.MSAL_CONFIGURATION_ATTR, None) + client_id: Optional[str] = getattr(msal_configuration, MsalPatch.MSAL_CLIENT_ID_ATTR, None) + + logger.info( + "Acquiring agentic application token via ManagedIdentityCredential for agent_app_instance_id=%s", + agent_app_instance_id, + ) + + # Use ManagedIdentityCredential rather than DefaultAzureCredential: in a + # hosted container the agentic token must come from the container's managed + # identity. ManagedIdentityCredential supports the FMI ``identity_config`` + # exchange but has no credential fallback chain, so it cannot silently pick + # up an unintended identity (for example a developer's Azure CLI login). + credential = ManagedIdentityCredential( + client_id=client_id or None, + identity_config={MsalPatch.FMI_PATH_KEY: agent_app_instance_id}, + ) + try: + token = await credential.get_token(MsalPatch.TOKEN_EXCHANGE_SCOPE) + return token.token + except Exception: # pylint: disable=broad-exception-caught + logger.exception( + "Failed to acquire agentic application token for agent_app_instance_id=%s", + agent_app_instance_id, + ) + return None + finally: + try: + await credential.close() + except Exception: # pylint: disable=broad-exception-caught + logger.debug("Error closing ManagedIdentityCredential", exc_info=True) + + MsalAuth.get_agentic_application_token = get_token_via_managed_identity + setattr(MsalAuth, MsalPatch.PATCH_FLAG, True) + logger.info("Patched MsalAuth.get_agentic_application_token -> ManagedIdentityCredential") + + +def build_m365_app( + *, + digital_worker: bool, + connection_config: Mapping[str, str], + storage: Optional[Storage] = None, + connection_manager: Optional[MsalConnectionManager] = None, + adapter: Optional[HttpAdapterBase] = None, + authorization: Optional[Authorization] = None, + agent_app: Optional[AgentApplication] = None, +) -> tuple[AgentApplication, HttpAdapterBase]: + """Build the M365 Agents SDK ``AgentApplication`` (and adapter) eagerly. + + Constructs the full M365 stack from ``connection_config``, or assembles it + from caller-supplied components. Any component left as ``None`` is created + from the resolved connection config. + + :keyword digital_worker: When ``True``, apply the FMI MSAL patch before + creating the connection manager (digital-worker model). + :paramtype digital_worker: bool + :keyword connection_config: The resolved M365 ``CONNECTIONS__*`` mapping. + :paramtype connection_config: Mapping[str, str] + :keyword storage: The storage backend for the built stack. Required when + building (``agent_app`` not supplied); ignored on the injected-``agent_app`` + fast path. :class:`ActivityAgentServerHost` resolves it via + ``_resolve_storage`` (the single place the default backend is chosen) + before calling this. + :paramtype storage: Optional[~microsoft_agents.hosting.core.Storage] + :keyword connection_manager: Optional connection manager (defaults to + ``MsalConnectionManager`` built from the connection settings). + :paramtype connection_manager: + Optional[~microsoft_agents.authentication.msal.MsalConnectionManager] + :keyword adapter: Optional channel adapter (defaults to ``HttpAdapterBase``). + :paramtype adapter: Optional[~microsoft_agents.hosting.core.HttpAdapterBase] + :keyword authorization: Optional ``Authorization`` instance. + :paramtype authorization: Optional[~microsoft_agents.hosting.core.Authorization] + :keyword agent_app: Optional, fully-built ``AgentApplication`` to use as-is. + When supplied, the other component arguments are ignored and the adapter + is taken from ``agent_app.adapter``. + :paramtype agent_app: Optional[~microsoft_agents.hosting.core.AgentApplication] + :return: A tuple of ``(agent_app, adapter)``. + :rtype: tuple[~microsoft_agents.hosting.core.AgentApplication, + ~microsoft_agents.hosting.core.HttpAdapterBase] + :raises ImportError: If the M365 Agents SDK is not installed. + """ + # Apply the FMI patch (digital-worker only) before any MsalConnectionManager + # is created. + if digital_worker: + _apply_msal_patches() + + # Fast path: a fully-built agent_app was injected (agent_app=). + # Host it as-is; the adapter is taken from agent_app.adapter. That property is + # typed as the ChannelServiceAdapter base; the M365 SDK builds it as an + # HttpAdapterBase and only ``process_activity`` (defined on the base) is used, + # so narrowing here is safe. + if agent_app is not None: + return agent_app, cast("HttpAdapterBase", agent_app.adapter) + + try: + # pylint: disable=import-error,no-name-in-module + from microsoft_agents.activity import load_configuration_from_env + from microsoft_agents.authentication.msal import MsalConnectionManager + from microsoft_agents.hosting.core import ( + AgentApplication, + Authorization, + HttpAdapterBase, + RestChannelServiceClientFactory, + TurnState, + ) + except ImportError as exc: + raise ImportError( + "ActivityAgentServerHost requires the M365 Agents SDK for the default " + "and injected-agent_app construction paths. Install: pip install " + "microsoft-agents-hosting-core microsoft-agents-authentication-msal " + "microsoft-agents-activity azure-identity." + ) from exc + + logger.info("Initializing M365 Agents SDK...") + + # The connection config is a flat ``CONNECTIONS__*`` mapping; the M365 SDK + # parses it into its nested configuration structure before the connection + # manager / authorization / app can consume it. + resolved_config = load_configuration_from_env(dict(connection_config)) + if storage is None: + raise ValueError( + "storage is required to build the M365 stack. Pass a storage backend, or " + "pass agent_app to host a pre-built AgentApplication as-is." + ) + resolved_storage: Storage = storage + resolved_cm: MsalConnectionManager = ( + connection_manager if connection_manager is not None else MsalConnectionManager(**resolved_config) + ) + if adapter is not None: + resolved_adapter: HttpAdapterBase = adapter + else: + client_factory = RestChannelServiceClientFactory(resolved_cm) + resolved_adapter = HttpAdapterBase(channel_service_client_factory=client_factory) + resolved_authorization: Authorization = ( + authorization if authorization is not None else Authorization(resolved_storage, resolved_cm, **resolved_config) + ) + built_app: AgentApplication = AgentApplication[TurnState]( + storage=resolved_storage, + adapter=resolved_adapter, + authorization=resolved_authorization, + **resolved_config, + ) + logger.info("M365 Agents SDK initialized successfully.") + return built_app, resolved_adapter + + +def build_bridge_handler( + agent_app: AgentApplication, + adapter: HttpAdapterBase, + *, + digital_worker: bool, + is_hosted: bool, + bot_app_id: str, +) -> BridgeHandler: + """Bind a Starlette request handler to an app + adapter + outbound-auth model. + + The outbound-auth inputs are captured here at build time; the returned handler + synthesizes the per-turn outbound claims, attaches them to the request, and + drives the Starlette :class:`~._cloud_adapter.StarletteCloudAdapter`. + + :param agent_app: The ``AgentApplication`` used to process each turn. + :type agent_app: ~microsoft_agents.hosting.core.AgentApplication + :param adapter: The HTTP adapter providing ``process_request``. + :type adapter: ~microsoft_agents.hosting.core.HttpAdapterBase + :keyword digital_worker: The selected outbound-auth model. + :paramtype digital_worker: bool + :keyword is_hosted: Whether the agent runs inside a Foundry-hosted container. + :paramtype is_hosted: bool + :keyword bot_app_id: The Bot Connector client id (empty when unset). + :paramtype bot_app_id: str + :return: An async Starlette request handler. + :rtype: Callable[[~starlette.requests.Request], Awaitable[~starlette.responses.Response]] + """ + from ._cloud_adapter import StarletteCloudAdapter + + cloud_adapter = StarletteCloudAdapter(adapter) + + async def bridge_handler(request: Request) -> Response: + # pylint: disable=import-error,no-name-in-module + from microsoft_agents.hosting.core import ClaimsIdentity + + # Synthesize the outbound claims for this turn and attach them to the + # request so the framework adapter reads them via get_claims_identity + # (mirrors how the SDK adapters read claims set by auth middleware). + request.state.claims_identity = _build_outbound_claims( + ClaimsIdentity, + digital_worker=digital_worker, + is_hosted=is_hosted, + bot_app_id=bot_app_id, + ) + return await cloud_adapter.process(request, agent_app) + + return bridge_handler + + +def _build_outbound_claims( + claims_cls: type[ClaimsIdentity], + *, + digital_worker: bool, + is_hosted: bool, + bot_app_id: str, +) -> ClaimsIdentity: + """Build the outbound ``ClaimsIdentity`` for the turn per the resolved values. + + :param claims_cls: The M365 ``ClaimsIdentity`` class (injected to keep the + M365 import local to the turn path). + :type claims_cls: type[~microsoft_agents.hosting.core.ClaimsIdentity] + :keyword digital_worker: The selected outbound-auth model. + :paramtype digital_worker: bool + :keyword is_hosted: Whether the agent runs inside a Foundry-hosted container. + :paramtype is_hosted: bool + :keyword bot_app_id: The Bot Connector client id (empty when unset). + :paramtype bot_app_id: str + :return: The claims identity to present to the adapter for the outbound reply. + :rtype: ~microsoft_agents.hosting.core.ClaimsIdentity + """ + if digital_worker: + # Digital-worker model: anonymous claims; the FMI patch supplies the + # outbound token via the federated-identity exchange. + return claims_cls({}, is_authenticated=False, authentication_type=OutboundAuth.AUTH_TYPE_ANONYMOUS) + + if use_anonymous_outbound(digital_worker=digital_worker, is_hosted=is_hosted, bot_app_id=bot_app_id): + # Simple model, running locally with no Bot Connector credential: no + # managed identity exists off-box to mint a Bot Connector token, so use + # anonymous claims (the adapter then skips outbound token minting). This + # enables local round-trips via local test channels (for example the + # Microsoft 365 Agents Playground emulator channel) without a Bot + # registration. + logger.warning( + "LOCAL DEV: FOUNDRY_HOSTING_ENVIRONMENT is unset and no Bot Connector " + "credential (%s) is configured; using anonymous outbound auth. This is " + "NOT valid for production and only works with local test channels such " + "as the Microsoft 365 Agents Playground 'emulator' channel.", + OutboundAuth.CLAIM_APP_ID, + ) + return claims_cls({}, is_authenticated=False, authentication_type=OutboundAuth.AUTH_TYPE_ANONYMOUS) + + # Simple model (default): present authenticated claims whose appid matches the + # service-connection client id (the agent instance identity). This makes the + # adapter use the real MSAL UserManagedIdentity connection for the outbound + # reply instead of an anonymous/empty token. + claim_dict = {OutboundAuth.CLAIM_APP_ID: bot_app_id, OutboundAuth.CLAIM_AUDIENCE: bot_app_id} if bot_app_id else {} + return claims_cls(claim_dict, is_authenticated=True, authentication_type=OutboundAuth.AUTH_TYPE_BEARER) diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_version.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_version.py new file mode 100644 index 000000000000..95e11ac1bdc7 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_version.py @@ -0,0 +1,5 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- + +VERSION = "1.0.0b4" diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/py.typed b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/py.typed new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/sdk/agentserver/azure-ai-agentserver-activity/cspell.json b/sdk/agentserver/azure-ai-agentserver-activity/cspell.json new file mode 100644 index 000000000000..01a36d9f77cf --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/cspell.json @@ -0,0 +1,17 @@ +{ + "ignoreWords": [ + "agentserver", + "MAIB", + "openapi", + "paramtype", + "rtype", + "starlette", + "TENANTID" + ], + "ignorePaths": [ + "*.csv", + "*.json", + "*.rst", + "samples/**" + ] +} diff --git a/sdk/agentserver/azure-ai-agentserver-activity/dev_requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/dev_requirements.txt new file mode 100644 index 000000000000..d426eb47e50d --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/dev_requirements.txt @@ -0,0 +1,6 @@ +# keep in sync with pyproject.toml#dependency-groups.dev +-e ../../../eng/tools/azure-sdk-tools +-e ../azure-ai-agentserver-core +pytest +pytest-asyncio +opentelemetry-sdk>=1.40.0 diff --git a/sdk/agentserver/azure-ai-agentserver-activity/mypy.ini b/sdk/agentserver/azure-ai-agentserver-activity/mypy.ini new file mode 100644 index 000000000000..4e8f9e6e999e --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/mypy.ini @@ -0,0 +1,5 @@ +[mypy] +explicit_package_bases = True + +[mypy-microsoft_agents.*] +ignore_missing_imports = true diff --git a/sdk/agentserver/azure-ai-agentserver-activity/pyproject.toml b/sdk/agentserver/azure-ai-agentserver-activity/pyproject.toml new file mode 100644 index 000000000000..fcc7d95e72b1 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/pyproject.toml @@ -0,0 +1,75 @@ +[project] +name = "azure-ai-agentserver-activity" +dynamic = ["version", "readme"] +description = "Activity protocol host for Azure AI Hosted Agents" +requires-python = ">=3.10" +authors = [ + { name = "Microsoft Corporation", email = "azpysdkhelp@microsoft.com" }, +] +license = "MIT" +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +keywords = ["azure", "azure sdk", "agent", "agentserver", "activity"] + +dependencies = [ + "azure-ai-agentserver-core>=2.0.0b7", + "opentelemetry-api>=1.40.0", + "aiohttp>=3.10.0,<4.0.0a0", +] + +[dependency-groups] +# keep in sync with dev_requirements.txt +dev = [ + "azure-sdk-tools", + "opentelemetry-sdk>=1.40.0", + "pytest-asyncio", + "pytest", +] + +[project.urls] +repository = "https://github.com/Azure/azure-sdk-for-python" + +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +exclude = [ + "tests*", + "samples*", + "doc*", + "azure", + "azure.ai", + "azure.ai.agentserver", +] + +[tool.setuptools.dynamic] +version = { attr = "azure.ai.agentserver.activity._version.VERSION" } +readme = { file = ["README.md"], content-type = "text/markdown" } + +[tool.setuptools.package-data] +"azure.ai.agentserver.activity" = ["py.typed"] + +[tool.azure-sdk-build] +analyze_python_version = "3.11" +breaking = false +mypy = true +pyright = true +verifytypes = false +latestdependency = false +mindependency = false +pylint = true +type_check_samples = false + +[tool.uv.sources] +azure-ai-agentserver-core = { path = "../azure-ai-agentserver-core", editable = true } +azure-sdk-tools = { path = "../../../eng/tools/azure-sdk-tools" } diff --git a/sdk/agentserver/azure-ai-agentserver-activity/pyrightconfig.json b/sdk/agentserver/azure-ai-agentserver-activity/pyrightconfig.json new file mode 100644 index 000000000000..f36c5a7fe0d3 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/pyrightconfig.json @@ -0,0 +1,11 @@ +{ + "reportOptionalMemberAccess": "warning", + "reportArgumentType": "warning", + "reportAttributeAccessIssue": "warning", + "reportMissingImports": "warning", + "reportGeneralTypeIssues": "warning", + "reportReturnType": "warning", + "exclude": [ + "**/samples/**" + ] +} diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/01-echo/main.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/01-echo/main.py new file mode 100644 index 000000000000..41a2ab13967a --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/01-echo/main.py @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Echo activity protocol agent. + +The simplest activity protocol agent: register handlers on the host's +``agent_app`` and echo the user's message back. +""" + +from azure.ai.agentserver.activity import ActivityAgentServerHost + +host = ActivityAgentServerHost() +app = host.agent_app + + +@app.activity("message") +async def on_message(context, state): + """Echo the user's message back.""" + user_text = context.activity.text or "" + if user_text.strip(): + reply = f"Echo: {user_text}" + await context.send_activity(reply) + + +@app.activity("conversationUpdate") +async def on_members_added(context, state): + """Welcome new members.""" + for member in context.activity.members_added or []: + if member.id != context.activity.recipient.id: + await context.send_activity(f"Welcome, {member.name}!") + + +@app.error +async def on_error(context, error): + """Handle unhandled errors.""" + await context.send_activity(f"Sorry, something went wrong: {error}") + + +if __name__ == "__main__": + host.run() diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/01-echo/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/01-echo/requirements.txt new file mode 100644 index 000000000000..67656cca36bc --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/01-echo/requirements.txt @@ -0,0 +1,4 @@ +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-activity +azure-identity \ No newline at end of file diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/02-custom-components/main.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/02-custom-components/main.py new file mode 100644 index 000000000000..ecf1ca7130dd --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/02-custom-components/main.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Echo activity protocol agent that overrides an M365 component. + +Same echo behavior as ``01-echo``, but overrides one of the M365 components the +host would otherwise build itself. Any of ``storage`` / ``connection_manager`` / +``adapter`` / ``authorization`` / ``connection_config`` can be passed; the host builds the +rest from the environment. This sample overrides ``storage`` as the concrete +example. + +Because the host still builds the connection manager itself, it derives the +``CONNECTIONS__*`` settings internally — no ``get_hosted_agent_env()`` call is +needed here. (You only resolve those yourself when you build the connection +manager — see ``03-self-hosted-app``.) + +Swap ``MemoryStorage`` for a durable M365 ``Storage`` implementation (e.g. +Cosmos DB / Blob) to persist ``TurnState`` across restarts. +""" + +from microsoft_agents.hosting.core import MemoryStorage + +from azure.ai.agentserver.activity import ActivityAgentServerHost + +# Override one component (storage here); everything else is built from the environment. +storage = MemoryStorage() + +host = ActivityAgentServerHost(storage=storage) +app = host.agent_app + + +@app.activity("message") +async def on_message(context, state): + """Echo the user's message back.""" + user_text = context.activity.text or "" + if user_text.strip(): + await context.send_activity(f"Echo (custom components): {user_text}") + + +@app.activity("conversationUpdate") +async def on_members_added(context, state): + """Welcome new members.""" + for member in context.activity.members_added or []: + if member.id != context.activity.recipient.id: + await context.send_activity(f"Welcome, {member.name}!") + + +@app.error +async def on_error(context, error): + """Handle unhandled errors.""" + await context.send_activity(f"Sorry, something went wrong: {error}") + + +if __name__ == "__main__": + host.run() diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/02-custom-components/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/02-custom-components/requirements.txt new file mode 100644 index 000000000000..bc8bbc5c489e --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/02-custom-components/requirements.txt @@ -0,0 +1,4 @@ +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-activity +azure-identity diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/03-self-hosted-app/main.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/03-self-hosted-app/main.py new file mode 100644 index 000000000000..8b854d11453b --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/03-self-hosted-app/main.py @@ -0,0 +1,77 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Self-Hosted Activity Agent - build the M365 AgentApplication yourself. + +The developer constructs the full M365 Agents SDK stack (``MsalConnectionManager``, +``HttpAdapterBase``, ``AgentApplication``) and hands the pre-built app to the host +via ``ActivityAgentServerHost(agent_app=...)``. The host drives it +through the Foundry activity turn pipeline (claims, parsing, delivery) for you. + +Use this pattern when you need full control over how the ``AgentApplication`` is +built (custom storage / connection manager / authorization) but still want the +Activity-protocol host to run it. +""" + +from azure.ai.agentserver.activity import ActivityAgentServerHost, get_hosted_agent_env + +from microsoft_agents.activity import Activity, load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.hosting.core import ( + AgentApplication, + Authorization, + HttpAdapterBase, + MemoryStorage, + RestChannelServiceClientFactory, + TurnContext, + TurnState, +) + +# Resolve the config the M365 connection manager needs. get_hosted_agent_env() +# returns a mapping (os.environ overlaid with the derived CONNECTIONS__* settings) +# WITHOUT mutating the process environment — pass it straight to the SDK loader. +config = load_configuration_from_env(get_hosted_agent_env()) +storage = MemoryStorage() +connection_manager = MsalConnectionManager(**config) +client_factory = RestChannelServiceClientFactory(connection_manager) +adapter = HttpAdapterBase(channel_service_client_factory=client_factory) +authorization = Authorization(storage, connection_manager, **config) +agent_app = AgentApplication[TurnState]( + storage=storage, + adapter=adapter, + authorization=authorization, + **config, +) + + +# ── Business logic ─────────────────────────────────────────────── + + +@agent_app.activity("message") +async def on_message(context: TurnContext, state: TurnState): + """Echo the user's message back.""" + user_text = context.activity.text or "" + await context.send_activity(Activity(type="typing")) + reply = f"[Self-Hosted] Echo: {user_text}" + await context.send_activity(reply) + + +@agent_app.activity("conversationUpdate") +async def on_members_added(context: TurnContext, state: TurnState): + """Welcome new members.""" + for member in context.activity.members_added or []: + if member.id != context.activity.recipient.id: + await context.send_activity(f"Welcome, {member.name}!") + + +@agent_app.error +async def on_error(context: TurnContext, error: Exception): + """Handle unhandled errors.""" + await context.send_activity("The agent encountered an error.") + + +# ── Foundry host with the pre-built AgentApplication ───────────── + +app = ActivityAgentServerHost(agent_app=agent_app) + +if __name__ == "__main__": + app.run() diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/03-self-hosted-app/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/03-self-hosted-app/requirements.txt new file mode 100644 index 000000000000..bc8bbc5c489e --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/03-self-hosted-app/requirements.txt @@ -0,0 +1,4 @@ +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-activity +azure-identity diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/04-custom-handler/main.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/04-custom-handler/main.py new file mode 100644 index 000000000000..7fa6718a32a4 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/04-custom-handler/main.py @@ -0,0 +1,92 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Echo activity protocol agent with a fully custom request handler. + +Demonstrates ``request_handler``: you own the request pipeline entirely. +The M365 Agents SDK is **not** initialized — your ``async`` handler receives the +raw Starlette ``Request`` (with the parsed activity dict on +``request.state.activity``) and returns a Starlette ``Response``. + +The host still provides the Foundry platform contract for free: the +``POST /activity/messages`` endpoint, session resolution, OpenTelemetry tracing +and W3C baggage, error-source classification, and the required response headers. +You just decide what each request does. + +Because there is no M365 SDK bridge here, **this handler makes the outbound +Teams reply itself** — it mints a Bot Connector token with the container's +managed identity and POSTs the reply to the channel's ``serviceUrl``. This is +what "own the pipeline" means: you control inbound *and* outbound. +""" + +import logging +from os import environ + +import aiohttp +from azure.identity.aio import DefaultAzureCredential +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from azure.ai.agentserver.activity import ActivityAgentServerHost + +logger = logging.getLogger("custom-handler") + +# Bot Connector token scope. The container's user-assigned managed identity +# (FOUNDRY_AGENT_INSTANCE_CLIENT_ID) is the agent's bot identity. +_BOT_CONNECTOR_SCOPE = "https://api.botframework.com/.default" +_INSTANCE_CLIENT_ID = environ.get("FOUNDRY_AGENT_INSTANCE_CLIENT_ID", "").strip() + + +async def _send_teams_reply(service_url: str, conversation_id: str, text: str) -> None: + """Mint a Bot Connector token and POST a message activity back to the channel.""" + cred_kwargs = {"managed_identity_client_id": _INSTANCE_CLIENT_ID} if _INSTANCE_CLIENT_ID else {} + credential = DefaultAzureCredential(**cred_kwargs) + try: + token = (await credential.get_token(_BOT_CONNECTOR_SCOPE)).token + finally: + await credential.close() + + url = f"{service_url.rstrip('/')}/v3/conversations/{conversation_id}/activities" + payload = {"type": "message", "text": text} + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=payload, headers=headers) as resp: + if resp.status >= 400: + body = await resp.text() + logger.error("Outbound reply failed | status=%s | body=%s", resp.status, body) + else: + logger.info("Outbound reply sent | status=%s", resp.status) + + +async def handle(request: Request) -> Response: + """Custom handler: you own inbound and outbound (no M365 SDK).""" + activity = request.state.activity + activity_type = activity.get("type", "") + text = (activity.get("text") or "").strip() + + logger.info("[HANDLER] type=%s | text=%s", activity_type, text) + + # "invoke" activities expect a synchronous JSON reply body (no outbound call). + if activity_type == "invoke": + return JSONResponse({"status": 200, "body": {"echo": text}}) + + # For a normal message, send the echo back over the Bot Connector channel. + service_url = (activity.get("serviceUrl") or "").strip() + conversation = activity.get("conversation") or {} + conversation_id = conversation.get("id", "") if isinstance(conversation, dict) else "" + + if activity_type == "message" and text and service_url and conversation_id: + try: + await _send_teams_reply(service_url, conversation_id, f"Echo (custom handler): {text}") + except Exception: # pragma: no cover - best-effort outbound + logger.exception("Failed to send outbound Teams reply") + + # Acknowledge receipt of the inbound activity. + return Response(status_code=202) + + +app = ActivityAgentServerHost(request_handler=handle) + + +if __name__ == "__main__": + app.run() diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/04-custom-handler/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/04-custom-handler/requirements.txt new file mode 100644 index 000000000000..094abc6b8006 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/04-custom-handler/requirements.txt @@ -0,0 +1,6 @@ +# The custom-handler path does not initialize the M365 Agents SDK, so the +# microsoft-agents-* packages are not required here. The handler makes the +# outbound Bot Connector reply itself, so it needs azure-identity (mint the +# token) and aiohttp (POST the reply). +azure-identity +aiohttp>=3.9.0 diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/05-multi-protocol/main.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/05-multi-protocol/main.py new file mode 100644 index 000000000000..9982132662b6 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/05-multi-protocol/main.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Multi-Protocol Agent - Activity + Invocations. + +Composes the Activity and Invocations protocols on a single server using +Python mixin inheritance. + +Both protocols share the same server on port 8088: + POST /activity/messages - Activity protocol (Teams/M365) + POST /invocations - Invocations protocol (HTTP API) +""" + +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from azure.ai.agentserver.activity import ActivityAgentServerHost +from azure.ai.agentserver.invocations import InvocationAgentServerHost + + +class MultiProtocolHost(ActivityAgentServerHost, InvocationAgentServerHost): + pass + + +host = MultiProtocolHost() +app = host.agent_app + + +@app.activity("message") +async def on_teams_message(context, state): + """Handle messages from Teams via Activity protocol.""" + user_text = context.activity.text or "" + if user_text.strip(): + await context.send_activity(f"[Multi-Protocol] Echo: {user_text}") + + +@host.invoke_handler +async def handle_invocation(request: Request) -> Response: + """Handle HTTP invocations via Invocations protocol.""" + data = await request.json() + message = data.get("message", "") + return JSONResponse( + { + "reply": f"[Multi-Protocol] Echo: {message}", + "protocol": "invocations", + } + ) + + +if __name__ == "__main__": + host.run() diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/05-multi-protocol/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/05-multi-protocol/requirements.txt new file mode 100644 index 000000000000..91c93fa48801 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/05-multi-protocol/requirements.txt @@ -0,0 +1,5 @@ +azure-ai-agentserver-invocations +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-activity +azure-identity diff --git a/sdk/agentserver/azure-ai-agentserver-activity/tests/conftest.py b/sdk/agentserver/azure-ai-agentserver-activity/tests/conftest.py new file mode 100644 index 000000000000..a41afe976b7e --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/tests/conftest.py @@ -0,0 +1,128 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Shared fixtures for activity protocol tests. + +Provides a tiny in-process ASGI test client (``asgi_client``) so the tests can +drive the Starlette ASGI app directly without depending on ``httpx``. +""" + +import json as _json + +import pytest + + +class _Headers: + """Case-insensitive, read-only view of response headers.""" + + def __init__(self, pairs): + self._store = {key.lower(): value for key, value in pairs} + + def __getitem__(self, key): + return self._store[key.lower()] + + def __contains__(self, key): + return key.lower() in self._store + + def get(self, key, default=None): + return self._store.get(key.lower(), default) + + +class _ASGIResponse: + """Minimal response object exposing the surface the tests use.""" + + def __init__(self, status_code, headers, body): + self.status_code = status_code + self.headers = _Headers(headers) + self._body = body + + def json(self): + return _json.loads(self._body.decode("utf-8")) + + @property + def text(self): + return self._body.decode("utf-8") + + +class _ASGIClient: + """In-process ASGI client that invokes an app via ``scope``/``receive``/``send``. + + Mirrors the subset of the ``httpx.AsyncClient`` surface the tests rely on + (``post`` / ``get`` returning an object with ``status_code`` / ``json()`` / + ``headers``) without taking an ``httpx`` dependency. + """ + + def __init__(self, app): + self._app = app + + async def __aenter__(self): + return self + + async def __aexit__(self, *_exc): + return False + + async def post(self, url, *, json=None, headers=None): + return await self._send("POST", url, json, headers) + + async def get(self, url, *, json=None, headers=None): + return await self._send("GET", url, json, headers) + + async def _send(self, method, url, json_body, headers): + path, _, query = url.partition("?") + req_headers = dict(headers or {}) + if json_body is not None: + body = _json.dumps(json_body).encode("utf-8") + req_headers.setdefault("content-type", "application/json") + else: + body = b"" + + scope = { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode("utf-8"), + "query_string": query.encode("utf-8"), + "root_path": "", + "headers": [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in req_headers.items()], + "server": ("testserver", 80), + "client": ("testclient", 50000), + } + + request_sent = False + + async def receive(): + nonlocal request_sent + if request_sent: + return {"type": "http.disconnect"} + request_sent = True + return {"type": "http.request", "body": body, "more_body": False} + + result = {"status": 500, "headers": []} + body_chunks = [] + + async def send(message): + if message["type"] == "http.response.start": + result["status"] = message["status"] + result["headers"] = [(k.decode("latin-1"), v.decode("latin-1")) for k, v in message.get("headers", [])] + elif message["type"] == "http.response.body": + body_chunks.append(message.get("body", b"")) + + await self._app(scope, receive, send) + return _ASGIResponse(result["status"], result["headers"], b"".join(body_chunks)) + + +@pytest.fixture +def asgi_client(): + """Return a factory that wraps an ASGI app in an in-process test client.""" + + def _factory(app): + return _ASGIClient(app) + + return _factory + + +def pytest_configure(config): + config.addinivalue_line("markers", "tracing_e2e: end-to-end tracing tests against live Application Insights") diff --git a/sdk/agentserver/azure-ai-agentserver-activity/tests/test_activity_id.py b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_activity_id.py new file mode 100644 index 000000000000..ab270dcfb853 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_activity_id.py @@ -0,0 +1,85 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Tests for activity ID handling (provided value used; missing -> UUID).""" + +import uuid + +import pytest +from starlette.responses import JSONResponse + +from azure.ai.agentserver.activity import ActivityAgentServerHost + + +@pytest.mark.asyncio +async def test_provided_activity_id_is_used(asgi_client): + async def handle(_request): + return JSONResponse({"ok": True}) + + app = ActivityAgentServerHost(request_handler=handle, configure_observability=None) + async with asgi_client(app) as client: + resp = await client.post( + "/activity/messages", + json={"type": "message", "text": "hi", "id": "my-activity-123"}, + headers={"Authorization": "Bearer test-token"}, + ) + + assert resp.status_code == 200 + assert resp.headers["x-agent-activity-id"] == "my-activity-123" + + +@pytest.mark.asyncio +async def test_missing_activity_id_generates_uuid(asgi_client): + async def handle(_request): + return JSONResponse({"ok": True}) + + app = ActivityAgentServerHost(request_handler=handle, configure_observability=None) + async with asgi_client(app) as client: + resp = await client.post( + "/activity/messages", + json={"type": "message", "text": "hi"}, + headers={"Authorization": "Bearer test-token"}, + ) + + assert resp.status_code == 200 + activity_id = resp.headers["x-agent-activity-id"] + # Should be a valid UUID + uuid.UUID(activity_id) + + +@pytest.mark.asyncio +async def test_oversized_activity_id_falls_back_to_uuid(asgi_client): + async def handle(_request): + return JSONResponse({"ok": True}) + + app = ActivityAgentServerHost(request_handler=handle, configure_observability=None) + async with asgi_client(app) as client: + resp = await client.post( + "/activity/messages", + json={"type": "message", "text": "hi", "id": "x" * 300}, + headers={"Authorization": "Bearer test-token"}, + ) + + assert resp.status_code == 200 + activity_id = resp.headers["x-agent-activity-id"] + assert len(activity_id) < 300 + uuid.UUID(activity_id) # fallback UUID + + +@pytest.mark.asyncio +async def test_malformed_activity_id_falls_back_to_uuid(asgi_client): + async def handle(_request): + return JSONResponse({"ok": True}) + + app = ActivityAgentServerHost(request_handler=handle, configure_observability=None) + async with asgi_client(app) as client: + resp = await client.post( + "/activity/messages", + json={"type": "message", "text": "hi", "id": "id with spaces &