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..3611c24a4576 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/CHANGELOG.md @@ -0,0 +1,17 @@ +# Release History + +## 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..4805e33a2811 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/README.md @@ -0,0 +1,82 @@ +# 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` for inbound activities. + +### Usage patterns + +**Decorator-based (recommended)** — zero SDK wiring: + +```python +from azure.ai.agentserver.activity import ActivityAgentServerHost + +app = ActivityAgentServerHost() + +@app.activity("message") +async def on_message(context, state): + await context.send_activity(f"Echo: {context.activity.text}") + +@app.error +async def on_error(context, error): + await context.send_activity(f"Error: {error}") + +app.run() +``` + +**Custom handler** — full control over the M365 SDK pipeline: + +```python +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(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-isolation-key` and `x-agent-chat-isolation-key` +- `traceparent`, `tracestate`, and `baggage` + +### Public API + +- `ActivityAgentServerHost` — the host class +- `apply_msal_patches()` — patches M365 SDK MSAL auth for Foundry containers (UserManagedIdentity with fmi_path) + +## Samples + +See [samples/README.md](samples/README.md) for runnable scenarios: + +- `simple_activity_agent` — echo bot with welcome, invoke, installation events +- `streaming_activity_agent` — Azure OpenAI streaming via `context.streaming_response` +- `cards_activity_agent` — Adaptive Cards, Hero, Thumbnail, Receipt cards +- `auto_signin_activity_agent` — OAuth auto sign-in with Graph and GitHub +- `semantic_kernel_activity_agent` — Semantic Kernel agent with tools and multi-turn +- `suggested_actions_activity_agent` — quick-reply buttons 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..eceab9b77da9 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/__init__.py @@ -0,0 +1,40 @@ +# --------------------------------------------------------- +# 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`. + +Decorator-based usage (recommended):: + + from azure.ai.agentserver.activity import ActivityAgentServerHost + + app = ActivityAgentServerHost() + + @app.activity("message") + async def on_message(context, state): + await context.send_activity(f"Echo: {context.activity.text}") + + app.run() + +Custom handler usage:: + + from azure.ai.agentserver.activity import ActivityAgentServerHost + + async def handle(request): + activity = request.state.activity + # Custom processing... + return Response(status_code=202) + + app = ActivityAgentServerHost(handler=handle) + app.run() +""" +__path__ = __import__("pkgutil").extend_path(__path__, __name__) + +from ._activity import ActivityAgentServerHost +from ._m365_bridge import _apply_msal_patches as apply_msal_patches +from ._version import VERSION + +__all__ = ["ActivityAgentServerHost", "apply_msal_patches"] +__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..05423ce86336 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_activity.py @@ -0,0 +1,447 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Activity protocol host for Azure AI Hosted Agents. + +Provides the activity protocol endpoint as a +:class:`~azure.ai.agentserver.core.AgentServerHost` subclass. +""" + +import contextvars +import inspect +import logging +import os +import re as _re +import threading +import uuid +from collections.abc import Awaitable, Callable +from typing import Any, Optional + +from opentelemetry import baggage as _otel_baggage, context as _otel_context, trace as _otel_trace +from opentelemetry.trace import Status as _OtelStatus +from opentelemetry.trace import StatusCode as _OtelStatusCode +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route + +from azure.ai.agentserver.core import AgentServerHost, create_error_response +from azure.ai.agentserver.core._platform_headers import ( + CHAT_ISOLATION_KEY, + ERROR_DETAIL, + ERROR_SOURCE, + MAX_ERROR_DETAIL_LENGTH, + PLATFORM_ERROR_TAG, + USER_ISOLATION_KEY, +) + +from ._constants import ActivityConstants + +logger = logging.getLogger("azure.ai.agentserver") + +_ERROR_SOURCE_UPSTREAM: str = "upstream" +_ERROR_SOURCE_PLATFORM: str = "platform" + + +_session_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("activity_session_id", default="") +_user_isolation_key_var: contextvars.ContextVar[str] = contextvars.ContextVar("activity_user_isolation_key", default="") +_chat_isolation_key_var: contextvars.ContextVar[str] = contextvars.ContextVar("activity_chat_isolation_key", default="") +_protocol_var: contextvars.ContextVar[str] = contextvars.ContextVar("activity_protocol", default=ActivityConstants.PROTOCOL) + + +class _ActivityLogFilter(logging.Filter): + """Attach per-turn structured scope fields to activity log records.""" + + def filter(self, record: logging.LogRecord) -> bool: + record.SessionId = _session_id_var.get("") # type: ignore[attr-defined] + record.UserIsolationKey = _user_isolation_key_var.get("") # type: ignore[attr-defined] + record.ChatIsolationKey = _chat_isolation_key_var.get("") # type: ignore[attr-defined] + record.Protocol = _protocol_var.get(ActivityConstants.PROTOCOL) # type: ignore[attr-defined] + return True + + +_log_filter_lock = threading.Lock() +_log_filter_installed = False + + +def _ensure_log_filter() -> None: + """Install activity log scope filter once.""" + global _log_filter_installed # pylint: disable=global-statement + if _log_filter_installed: + return + with _log_filter_lock: + if _log_filter_installed: + return + logger.addFilter(_ActivityLogFilter()) + _log_filter_installed = True + + +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. + :param error_source: The error source value (user/platform/upstream). + :param error_detail: Optional detail string for platform errors. + :return: A new dict containing the original headers plus error source headers. + """ + merged = {**headers, ERROR_SOURCE: error_source} + if error_detail: + merged[ERROR_DETAIL] = error_detail + return merged + + +_SAFE_ID_PATTERN = _re.compile(r"^[a-zA-Z0-9\-_.:]+$") +_MAX_ID_LENGTH = 256 + + +def _sanitize_id(value: str) -> str: + """Validate an ID for safe use in HTTP headers and logs. + + Accepts alphanumeric characters plus ``-_.:`` up to 256 characters. + Returns a fallback UUID for invalid or oversized values. + """ + if not value or len(value) > _MAX_ID_LENGTH or not _SAFE_ID_PATTERN.match(value): + return str(uuid.uuid4()) + return value + + +def _classify_error(exc: BaseException) -> tuple[str, Optional[str]]: + """Classify an exception: platform-tagged -> (platform, detail), else -> (upstream, None).""" + 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 _ERROR_SOURCE_PLATFORM, detail + return _ERROR_SOURCE_UPSTREAM, None + + +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``. Use the decorator + methods to register M365 SDK activity handlers, or pass a custom + ``handler`` callable for full control. + + When no ``handler`` is provided, the M365 Agents SDK is auto-initialized + from environment variables. + + Usage:: + + from azure.ai.agentserver.activity import ActivityAgentServerHost + + app = ActivityAgentServerHost() + + @app.activity("message") + async def on_message(context, state): + await context.send_activity(f"Echo: {context.activity.text}") + + app.run() + + :param handler: Optional custom handler function. When provided, the + decorator API is bypassed and the handler receives the raw Starlette + ``Request`` with ``request.state.activity`` set to the parsed + activity dict. + :type handler: Optional[Callable[[Request], Awaitable[Response]]] + """ + + _INSTRUMENTATION_SCOPE = "Azure.AI.AgentServer.Activity" + + def __init__( + self, + *, + handler: Optional[Callable[[Request], Awaitable[Response]]] = None, + **kwargs: Any, + ) -> None: + # Initialize default env vars before bridge/app setup. + self._initialize_default_env_vars() + + if handler is not None and not inspect.iscoroutinefunction(handler): + raise TypeError( + f"handler must be an async function, got {type(handler).__name__}. " + "Use 'async def' to define your handler." + ) + + # explicit handler: user owns the processing pipeline + # no handler: use built-in M365 bridge + decorators + self._handler = handler + + activity_routes: list[Any] = [ + Route( + "/activity/messages", + self._create_activity_endpoint, + methods=["POST"], + name="create_activity", + ), + Route( + "/api/messages", + self._create_activity_endpoint, + methods=["POST"], + name="create_activity_api_messages", + ), + ] + + existing = list(kwargs.pop("routes", None) or []) + super().__init__(routes=existing + activity_routes, **kwargs) + + # ------------------------------------------------------------------ + # Handler decorators + # ------------------------------------------------------------------ + + def _initialize_default_env_vars(self) -> None: + """Initialize connection-related env vars used by the M365 SDK. + + Precedence order is: + 1. Existing explicit connection env vars + 2. Values derived from Foundry-native env vars + 3. Static defaults for non-critical options + """ + + def _get_nonempty(name: str) -> str: + return os.environ.get(name, "").strip() + + def _set_if_missing(name: str, value: str) -> None: + if value and not _get_nonempty(name): + os.environ[name] = value + + defaults = { + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHTYPE": "UserManagedIdentity", + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__SCOPES__0": "5a807f24-c9de-44ee-a3a7-329e88a00ffc/.default", + "CONNECTIONSMAP__0__SERVICEURL": "*", + "CONNECTIONSMAP__0__CONNECTION": "SERVICE_CONNECTION", + } + for key, value in defaults.items(): + _set_if_missing(key, value) + + foundry_blueprint_client_id = _get_nonempty("FOUNDRY_AGENT_BLUEPRINT_CLIENT_ID") + foundry_tenant_id = _get_nonempty("FOUNDRY_AGENT_TENANT_ID") + + _set_if_missing( + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID", + foundry_blueprint_client_id, + ) + _set_if_missing( + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID", + foundry_tenant_id, + ) + _set_if_missing( + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHORITY", + f"https://login.microsoftonline.com/{foundry_tenant_id}" if foundry_tenant_id else "", + ) + + def activity(self, activity_type: str): + """Register a handler for a specific activity type. + + Usage:: + + @app.activity("message") + async def on_message(context, state): + await context.send_activity(f"Echo: {context.activity.text}") + + :param activity_type: The activity type to handle (e.g., "message", "invoke"). + :type activity_type: str + """ + def decorator(fn): + from ._m365_bridge import _get_or_create_lazy_app + lazy_app = _get_or_create_lazy_app() + lazy_app.activity(activity_type)(fn) + # Wire up the bridge handler if not already set + if self._handler is None: + from ._m365_bridge import create_bridge_handler + self._handler = create_bridge_handler + return fn + return decorator + + def error(self, fn): + """Register an error handler. + + Usage:: + + @app.error + async def on_error(context, error): + await context.send_activity(f"Error: {error}") + + :param fn: Async error handler function. + """ + from ._m365_bridge import _get_or_create_lazy_app + lazy_app = _get_or_create_lazy_app() + lazy_app.error(fn) + if self._handler is None: + from ._m365_bridge import create_bridge_handler + self._handler = create_bridge_handler + return fn + + def _resolve_session_id(self, request: Request) -> str: + query_session_id = request.query_params.get("agent_session_id") + 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 _build_span_name(self) -> str: + agent_name = (self.config.agent_name or "").strip() + agent_version = (self.config.agent_version or "").strip() + if agent_name and agent_version: + return f"handle_activity {agent_name}:{agent_version}" + if agent_name: + return f"handle_activity {agent_name}" + return "handle_activity" + + def _apply_trace_tags(self, span: Any, session_id: str) -> None: + agent_name = (self.config.agent_name or "").strip() + agent_version = (self.config.agent_version or "").strip() + if agent_name and agent_version: + agent_id = f"{agent_name}:{agent_version}" + elif agent_name: + agent_id = agent_name + else: + agent_id = "" + + span.set_attribute("service.name", "azure.ai.agentserver") + span.set_attribute("gen_ai.provider.name", "AzureAI Hosted Agents") + span.set_attribute("gen_ai.operation.name", "handle_activity") + span.set_attribute("gen_ai.agent.id", agent_id) + if agent_name: + span.set_attribute("gen_ai.agent.name", agent_name) + if agent_version: + span.set_attribute("gen_ai.agent.version", agent_version) + if session_id: + span.set_attribute("gen_ai.conversation.id", session_id) + + span.set_attribute(ActivityConstants.ATTR_SPAN_SESSION_ID, session_id or "") + span.set_attribute(ActivityConstants.ATTR_SPAN_PROTOCOL, ActivityConstants.PROTOCOL) + span.set_attribute("microsoft.foundry.project.id", os.environ.get("FOUNDRY_PROJECT_ARM_ID", "")) + + def _add_required_response_headers(self, response: Response, session_id: str) -> None: + response.headers[ActivityConstants.SESSION_ID_HEADER] = session_id + + async def _create_activity_endpoint(self, request: Request) -> Response: + """Handle inbound POST to /activity/messages or /api/messages.""" + logger.debug( + "Activity endpoint hit | method=%s | path=%s | query=%s | content-type=%s", + request.method, request.url.path, str(request.query_params), + request.headers.get("content-type", ""), + ) + logger.debug("Activity endpoint headers: %s", dict(request.headers)) + + inbound_conversation_id = request.headers.get(ActivityConstants.CONVERSATION_ID_HEADER, "") + inbound_user_isolation_key = request.headers.get(USER_ISOLATION_KEY, "") + inbound_chat_isolation_key = request.headers.get(CHAT_ISOLATION_KEY, "") + + try: + payload = await request.json() + except Exception: # pylint: disable=broad-exception-caught + response = create_error_response( + "invalid_request", + "Request body must be valid JSON", + status_code=400, + headers=_apply_error_source_headers({}, _ERROR_SOURCE_UPSTREAM), + ) + self._add_required_response_headers(response, "") + return response + + if not isinstance(payload, dict): + response = create_error_response( + "invalid_request", + "Activity payload must be a JSON object", + status_code=400, + headers=_apply_error_source_headers({}, _ERROR_SOURCE_UPSTREAM), + ) + self._add_required_response_headers(response, "") + return response + + activity_id = payload.get("id", "") if isinstance(payload.get("id"), str) else "" + if not activity_id.strip(): + activity_id = str(uuid.uuid4()) + else: + activity_id = _sanitize_id(activity_id) + + session_id = _sanitize_id(self._resolve_session_id(request)) + + logger.debug( + "Activity parsed | type=%s | activity_id=%s | session_id=%s | text=%s | serviceUrl=%s | channelId=%s", + payload.get("type", "?"), activity_id, session_id, + str(payload.get("text", ""))[:100], payload.get("serviceUrl", ""), payload.get("channelId", ""), + ) + + request.state.activity = payload + request.state.activity_id = activity_id + request.state.session_id = session_id + request.state.user_isolation_key = inbound_user_isolation_key + request.state.chat_isolation_key = inbound_chat_isolation_key + + _ensure_log_filter() + session_token = _session_id_var.set(session_id) + user_token = _user_isolation_key_var.set(inbound_user_isolation_key) + chat_token = _chat_isolation_key_var.set(inbound_chat_isolation_key) + protocol_token = _protocol_var.set(ActivityConstants.PROTOCOL) + + tracer = _otel_trace.get_tracer(self._INSTRUMENTATION_SCOPE) + baggage_ctx = _otel_context.get_current() + baggage_ctx = _otel_baggage.set_baggage( + "azure.ai.agentserver.session_id", session_id or "", context=baggage_ctx + ) + baggage_ctx = _otel_baggage.set_baggage( + "azure.ai.agentserver.protocol", ActivityConstants.PROTOCOL, context=baggage_ctx + ) + baggage_token = _otel_context.attach(baggage_ctx) + + try: + with tracer.start_as_current_span(self._build_span_name()) as span: + self._apply_trace_tags(span, session_id) + try: + if self._handler is None: + raise NotImplementedError( + "No activity handler registered. Use the @app.activity() decorator " + "or pass a handler= callable to ActivityAgentServerHost()." + ) + response = await self._handler(request) + + response.headers[ActivityConstants.ACTIVITY_ID_HEADER] = activity_id + self._add_required_response_headers(response, session_id) + return response + except Exception as exc: # pylint: disable=broad-exception-caught + error_source, error_detail = _classify_error(exc) + logger.error("Error processing activity %s: %s", activity_id, exc, exc_info=True) + + # Record error on the span (still inside `with` block) + if span.is_recording(): + span.set_status(_OtelStatus(_OtelStatusCode.ERROR, str(exc))) + span.record_exception(exc) + span.set_attribute("error.type", type(exc).__name__) + span.set_attribute("otel.status.description", str(exc)) + span.set_attribute(ActivityConstants.ATTR_SPAN_ERROR_CODE, type(exc).__name__) + span.set_attribute(ActivityConstants.ATTR_SPAN_ERROR_MESSAGE, str(exc)) + + response = create_error_response( + "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: + _session_id_var.reset(session_token) + _user_isolation_key_var.reset(user_token) + _chat_isolation_key_var.reset(chat_token) + _protocol_var.reset(protocol_token) + try: + _otel_context.detach(baggage_token) + except ValueError: + pass 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..49b47565d763 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_constants.py @@ -0,0 +1,26 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +from azure.ai.agentserver.core._platform_headers import SESSION_ID as _SESSION_ID # pylint: disable=import-error,no-name-in-module + + +class ActivityConstants: + """Activity protocol constants. + + Protocol-specific headers and telemetry attribute keys for activity + endpoint handling. Cross-cutting header names (for example session ID) + are imported from :mod:`azure.ai.agentserver.core._platform_headers`. + """ + + PROTOCOL = "activity" + + # Request / response headers + ACTIVITY_ID_HEADER = "x-agent-activity-id" + SESSION_ID_HEADER = _SESSION_ID + CONVERSATION_ID_HEADER = "x-agent-conversation-id" + + # Span attribute keys + ATTR_SPAN_SESSION_ID = "azure.ai.agentserver.activity.session_id" + ATTR_SPAN_PROTOCOL = "azure.ai.agentserver.activity.protocol" + ATTR_SPAN_ERROR_CODE = "azure.ai.agentserver.activity.error.code" + ATTR_SPAN_ERROR_MESSAGE = "azure.ai.agentserver.activity.error.message" 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..fa13f1a8243f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_m365_bridge.py @@ -0,0 +1,267 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""M365 Agents SDK bridge for the Activity protocol host. + +Provides auto-initialization of the M365 Agents SDK stack from +environment variables, MSAL auth patches for Foundry containers, +and a bridge function that converts activity dicts into M365 SDK +turn processing. + +This module is used internally by :class:`ActivityAgentServerHost` +when decorator-based handlers are registered. Users who pass their +own ``handler`` callable bypass this module entirely. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Optional + +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +logger = logging.getLogger("azure.ai.agentserver") + +# Lazy imports — these are only needed when the bridge is actually used. +# This avoids hard dependency failures if M365 SDK isn't installed. +_m365_initialized = False +_adapter = None +_agent_app = None +_connection_manager = None + + +def _apply_msal_patches() -> None: + """Apply MSAL auth patches for Foundry container MAIB auth. + + When AUTH_TYPE is UserManagedIdentity, the stock MsalAuth uses + ManagedIdentityClient which doesn't support fmi_path. This patch + replaces get_agentic_application_token with DefaultAzureCredential. + """ + try: + from microsoft_agents.authentication.msal.msal_auth import MsalAuth + except ImportError: + logger.debug("microsoft-agents-authentication-msal not installed; skipping MSAL patches") + return + + _PATCH_FLAG = "_activity_sdk_msal_patched" + if getattr(MsalAuth, _PATCH_FLAG, False): + return + + async def _get_token_via_dac(self, tenant_id: str, agent_app_instance_id: str) -> Optional[str]: + from azure.identity.aio import DefaultAzureCredential + + if not agent_app_instance_id: + from microsoft_agents.authentication.msal.errors import authentication_errors + raise ValueError(str(authentication_errors.AgentApplicationInstanceIdRequired)) + + logger.info( + "[activity-bridge] Acquiring agentic application token via " + "DefaultAzureCredential for agent_app_instance_id=%s", + agent_app_instance_id, + ) + + client_id = getattr(self._msal_configuration, "CLIENT_ID", None) + credential_kwargs: dict[str, Any] = { + "identity_config": {"fmi_path": agent_app_instance_id}, + } + if client_id: + credential_kwargs["managed_identity_client_id"] = client_id + + credential = DefaultAzureCredential(**credential_kwargs) + try: + token = await credential.get_token("api://AzureADTokenExchange/.default") + return token.token + except Exception: + 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: + pass + + MsalAuth.get_agentic_application_token = _get_token_via_dac + setattr(MsalAuth, _PATCH_FLAG, True) + logger.info("Patched MsalAuth.get_agentic_application_token → DefaultAzureCredential") + + +def _ensure_m365_initialized(): + """Lazily initialize the M365 Agents SDK from environment variables. + + Called on first request when decorators are used. Idempotent. + """ + global _m365_initialized, _adapter, _agent_app, _connection_manager + + if _m365_initialized: + return _agent_app, _adapter + + try: + 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, + TurnState, + ) + except ImportError as exc: + raise ImportError( + "Activity decorator handlers require the M365 Agents SDK. " + "Install: pip install microsoft-agents-hosting-core " + "microsoft-agents-authentication-msal microsoft-agents-activity azure-identity" + ) from exc + + # Apply MSAL patches before any MsalConnectionManager is created + _apply_msal_patches() + + logger.info("Initializing M365 Agents SDK...") + config = load_configuration_from_env(os.environ) + 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, + ) + _m365_initialized = True + logger.info("M365 Agents SDK initialized successfully.") + return _agent_app, _adapter + + +async def create_bridge_handler(request: Request) -> Response: + """Built-in bridge handler for decorator-based agents. + + Converts the activity dict (set by ActivityAgentServerHost on + request.state) into an M365 SDK Activity and processes it through + the AgentApplication turn pipeline. + + On first call, initializes the M365 SDK and replays any pending + handler registrations captured by the lazy proxy. + """ + from microsoft_agents.activity import Activity + from microsoft_agents.hosting.core import ClaimsIdentity + + global _lazy_agent_app + agent_app, adapter = _ensure_m365_initialized() + + # Replay pending decorator registrations onto the real AgentApplication + if _lazy_agent_app is not None and not _lazy_agent_app._replayed: + _lazy_agent_app._replay_on(agent_app) + + activity_dict = request.state.activity + activity_type = activity_dict.get("type", "unknown") + session_id = request.state.session_id + + logger.info( + "Bridge: activity received | type=%s | session=%s", + activity_type, session_id, + ) + logger.debug( + "Bridge: activity details | conversation=%s | serviceUrl=%s | channelId=%s | from=%s", + activity_dict.get("conversation", {}).get("id", "?") if isinstance(activity_dict.get("conversation"), dict) else "?", + activity_dict.get("serviceUrl", ""), + activity_dict.get("channelId", ""), + activity_dict.get("from", {}).get("id", "?") if isinstance(activity_dict.get("from"), dict) else "?", + ) + + activity = Activity.model_validate(activity_dict) + + if not activity.type or not activity.conversation or not activity.conversation.id: + logger.warning( + "Bridge: rejecting activity with 400 | type=%s | has_conversation=%s | conversation_id=%s", + activity.type, activity.conversation is not None, + activity.conversation.id if activity.conversation else "None", + ) + return JSONResponse( + status_code=400, + content={"error": {"code": "invalid_request", "message": "Activity must have type and conversation.id"}}, + ) + + claims = ClaimsIdentity({}, is_authenticated=False, authentication_type="Anonymous") + + try: + invoke_response = await adapter.process_activity(claims, activity, agent_app.on_turn) + except PermissionError: + logger.error("Permission denied processing activity | type=%s", activity_type) + return Response(status_code=401) + except TypeError as exc: + logger.warning("TypeError processing activity (likely missing serviceUrl) | type=%s | error=%s", activity_type, exc) + return Response(status_code=202) + except Exception: + # Re-raise so the outer _create_activity_endpoint can classify + # the error and return 500 with proper x-platform-error-source. + raise + + if activity.type == "invoke" or activity.delivery_mode == "expectReplies": + if invoke_response is not None: + return JSONResponse(content=invoke_response.body, status_code=invoke_response.status) + return JSONResponse(content={}, status_code=200) + + return Response(status_code=202) + + +class _LazyAgentApp: + """Proxy that defers AgentApplication access until first request.""" + + def __init__(self): + self._pending_registrations: list = [] + self._replayed = False + + def activity(self, activity_type: str): + """Capture an activity handler registration for later replay.""" + def decorator(fn): + self._pending_registrations.append(("activity", activity_type, fn)) + return fn + return decorator + + def error(self, fn): + """Capture an error handler registration for later replay.""" + self._pending_registrations.append(("error", None, fn)) + return fn + + def _replay_on(self, agent_app): + """Replay all captured registrations onto the real AgentApplication. + + Idempotent — only replays once even if called concurrently. + """ + if self._replayed: + return + self._replayed = True + for kind, arg, fn in self._pending_registrations: + if kind == "activity": + agent_app.activity(arg)(fn) + elif kind == "error": + agent_app.error(fn) + self._pending_registrations.clear() + + +# Module-level lazy proxy — shared across all decorator calls +_lazy_agent_app: Optional[_LazyAgentApp] = None + + +def _get_or_create_lazy_app() -> _LazyAgentApp: + global _lazy_agent_app + if _lazy_agent_app is None: + _lazy_agent_app = _LazyAgentApp() + return _lazy_agent_app + + +def _reset_for_testing() -> None: + """Reset all module-level state. For test isolation only.""" + global _m365_initialized, _adapter, _agent_app, _connection_manager, _lazy_agent_app + _m365_initialized = False + _adapter = None + _agent_app = None + _connection_manager = None + _lazy_agent_app = None 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..67d209a8cafd --- /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.0b1" 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..c115521f62ea --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/cspell.json @@ -0,0 +1,15 @@ +{ + "ignoreWords": [ + "agentserver", + "openapi", + "paramtype", + "rtype", + "starlette" + ], + "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..25c128efb198 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/dev_requirements.txt @@ -0,0 +1,7 @@ +# keep in sync with pyproject.toml#dependency-groups.dev +-e ../../../eng/tools/azure-sdk-tools +-e ../azure-ai-agentserver-core +pytest +httpx +pytest-asyncio +opentelemetry-sdk>=1.40.0 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..c379e8cec091 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/pyproject.toml @@ -0,0 +1,74 @@ +[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.0b4", + "opentelemetry-api>=1.40.0", +] + +[dependency-groups] +# keep in sync with dev_requirements.txt +dev = [ + "azure-sdk-tools", + "httpx", + "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 +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/echo-agent/main.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/echo-agent/main.py new file mode 100644 index 000000000000..8889b7abebb9 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/echo-agent/main.py @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tier 1 - Zero-Config Activity Protocol Agent. + +The simplest possible activity protocol agent. The package auto-initializes +the M365 Agents SDK from environment variables, applies MSAL auth patches, +and bridges activities to the AgentApplication turn pipeline. + +You write only handler logic - no SDK wiring needed. +""" + +from azure.ai.agentserver.activity import ActivityAgentServerHost + +app = ActivityAgentServerHost() + + +@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__": + app.run() \ No newline at end of file diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/echo-agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/echo-agent/requirements.txt new file mode 100644 index 000000000000..67656cca36bc --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/echo-agent/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/multi-protocol-agent/main.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/multi-protocol-agent/main.py new file mode 100644 index 000000000000..7ec09f023cc2 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/multi-protocol-agent/main.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Multi-Protocol Agent - Activity + Invocations. + +Demonstrates composing Activity and Invocations protocols on a single +server using Python mixin inheritance (Tier 2 - Builder pattern). + +Both protocols share the same server on port 8088: + POST /activity/messages - Activity protocol (Teams/M365) + POST /api/messages - Activity protocol (Bot Framework compat) + 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 + + +app = MultiProtocolHost() + + +@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}") + + +@app.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__": + app.run() + diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/multi-protocol-agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/multi-protocol-agent/requirements.txt new file mode 100644 index 000000000000..bc8bbc5c489e --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/multi-protocol-agent/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/self-hosted-agent/main.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/self-hosted-agent/main.py new file mode 100644 index 000000000000..542888cfb41f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/self-hosted-agent/main.py @@ -0,0 +1,113 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Self-Hosted Activity Agent - Full M365 SDK Control. + +Demonstrates the handler pattern (Tier 3) where the developer owns the +full M365 Agents SDK pipeline: MsalConnectionManager, HttpAdapterBase, +AgentApplication, and a custom bridge handler. + +Use this pattern when you need: +- Direct access to M365 SDK features (auth_handlers, regex message matching) +- Custom error handling or response logic +- Full control over the activity processing pipeline +""" + +from os import environ + +from starlette.responses import JSONResponse, Response + +from azure.ai.agentserver.activity import ActivityAgentServerHost, apply_msal_patches + +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, + ClaimsIdentity, + HttpAdapterBase, + MemoryStorage, + RestChannelServiceClientFactory, + TurnContext, + TurnState, +) + +# ── M365 SDK setup ─────────────────────────────────────────────── +# Apply MSAL patches before creating MsalConnectionManager. +apply_msal_patches() + +config = load_configuration_from_env(environ) +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 custom handler ───────────────────────────── + +async def handle(request) -> Response: + """Bridge to M365 SDK - parses activity and delegates to agent_app.""" + activity_dict = request.state.activity + + activity = Activity.model_validate(activity_dict) + + if not activity.type or not activity.conversation or not activity.conversation.id: + return JSONResponse( + status_code=400, + content={"error": {"code": "invalid_request", "message": "Missing type or conversation.id"}}, + ) + + claims = ClaimsIdentity({}, is_authenticated=False, authentication_type="Anonymous") + + try: + invoke_response = await adapter.process_activity(claims, activity, agent_app.on_turn) + except PermissionError: + return Response(status_code=401) + except TypeError: + return Response(status_code=202) + except Exception: + return Response(status_code=202) + + if activity.type == "invoke" or activity.delivery_mode == "expectReplies": + if invoke_response is not None: + return JSONResponse(content=invoke_response.body, status_code=invoke_response.status) + return JSONResponse(content={}, status_code=200) + + return Response(status_code=202) + + +app = ActivityAgentServerHost(handler=handle) + +if __name__ == "__main__": + app.run() \ No newline at end of file diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/self-hosted-agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/self-hosted-agent/requirements.txt new file mode 100644 index 000000000000..bc8bbc5c489e --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/self-hosted-agent/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/tests/conftest.py b/sdk/agentserver/azure-ai-agentserver-activity/tests/conftest.py new file mode 100644 index 000000000000..395853748275 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/tests/conftest.py @@ -0,0 +1,27 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Shared fixtures for activity protocol tests.""" + +import pytest +from httpx import ASGITransport, AsyncClient +from starlette.responses import JSONResponse, Response + +from azure.ai.agentserver.activity import ActivityAgentServerHost + + +def pytest_configure(config): + config.addinivalue_line("markers", "tracing_e2e: end-to-end tracing tests against live Application Insights") + + +@pytest.fixture +async def activity_client(): + async def on_message(request) -> Response: + activity = request.state.activity + return JSONResponse({"type": "message", "text": f"echo:{activity.get('text', '')}"}) + + app = ActivityAgentServerHost(handler=on_message, configure_observability=None) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client diff --git a/sdk/agentserver/azure-ai-agentserver-activity/tests/test_decorator_pattern.py b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_decorator_pattern.py new file mode 100644 index 000000000000..e973a09b9bce --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_decorator_pattern.py @@ -0,0 +1,35 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Tests for the decorator-based activity handler pattern.""" + +import pytest +from httpx import ASGITransport, AsyncClient +from starlette.responses import JSONResponse + +from azure.ai.agentserver.activity import ActivityAgentServerHost + + +@pytest.mark.asyncio +async def test_decorator_registers_handler(): + """Verify that @app.activity() wires up the bridge handler.""" + app = ActivityAgentServerHost(configure_observability=None) + + @app.activity("message") + async def on_message(context, state): + pass + + # After decorating, _handler should be set to the bridge + assert app._handler is not None + + +@pytest.mark.asyncio +async def test_error_decorator_registers_handler(): + """Verify that @app.error wires up the bridge handler.""" + app = ActivityAgentServerHost(configure_observability=None) + + @app.error + async def on_error(context, error): + pass + + assert app._handler is not None diff --git a/sdk/agentserver/azure-ai-agentserver-activity/tests/test_error_source_classification.py b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_error_source_classification.py new file mode 100644 index 000000000000..b52c0b5eaa9e --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_error_source_classification.py @@ -0,0 +1,50 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Tests for x-platform-error-source classification in activity endpoints.""" + +import pytest +from azure.ai.agentserver.core._platform_headers import ERROR_DETAIL, ERROR_SOURCE, PLATFORM_ERROR_TAG +from httpx import ASGITransport, AsyncClient + +from azure.ai.agentserver.activity import ActivityAgentServerHost + + +@pytest.mark.asyncio +async def test_upstream_handler_error_is_classified_upstream(): + async def handle(request): # pylint: disable=unused-argument + raise RuntimeError("handler bug") + + app = ActivityAgentServerHost(handler=handle, configure_observability=None) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + resp = await client.post( + "/activity/messages", + json={"type": "message", "text": "hello"}, + headers={"Authorization": "Bearer test-token", "x-agent-session-id": "session-123"}, + ) + + assert resp.status_code == 500 + assert resp.headers[ERROR_SOURCE] == "upstream" + assert ERROR_DETAIL not in resp.headers + + +@pytest.mark.asyncio +async def test_platform_tagged_error_is_classified_platform_with_detail(): + async def handle(request): # pylint: disable=unused-argument + exc = RuntimeError("platform storage failure") + setattr(exc, PLATFORM_ERROR_TAG, True) + raise exc + + app = ActivityAgentServerHost(handler=handle, configure_observability=None) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + resp = await client.post( + "/activity/messages", + json={"type": "message", "text": "hello"}, + headers={"Authorization": "Bearer test-token", "x-agent-session-id": "session-123"}, + ) + + assert resp.status_code == 500 + assert resp.headers[ERROR_SOURCE] == "platform" + assert "platform storage failure" in resp.headers[ERROR_DETAIL] diff --git a/sdk/agentserver/azure-ai-agentserver-activity/tests/test_id_sanitization.py b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_id_sanitization.py new file mode 100644 index 000000000000..476a52d4a5c6 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_id_sanitization.py @@ -0,0 +1,90 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Tests for activity ID sanitization (defense in depth).""" + +import uuid + +import pytest +from httpx import ASGITransport, AsyncClient +from starlette.responses import JSONResponse + +from azure.ai.agentserver.activity import ActivityAgentServerHost + + +@pytest.mark.asyncio +async def test_provided_activity_id_is_used(): + async def handle(request): # pylint: disable=unused-argument + return JSONResponse({"ok": True}) + + app = ActivityAgentServerHost(handler=handle, configure_observability=None) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") 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(): + async def handle(request): # pylint: disable=unused-argument + return JSONResponse({"ok": True}) + + app = ActivityAgentServerHost(handler=handle, configure_observability=None) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") 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_is_sanitized(): + async def handle(request): # pylint: disable=unused-argument + return JSONResponse({"ok": True}) + + app = ActivityAgentServerHost(handler=handle, configure_observability=None) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") 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) # should be a fallback UUID + + +@pytest.mark.asyncio +async def test_malformed_activity_id_is_sanitized(): + async def handle(request): # pylint: disable=unused-argument + return JSONResponse({"ok": True}) + + app = ActivityAgentServerHost(handler=handle, configure_observability=None) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + resp = await client.post( + "/activity/messages", + json={"type": "message", "text": "hi", "id": "id with spaces & ") + assert "