From 538a4aff878a9aa835700a24219f586f74f6888e Mon Sep 17 00:00:00 2001 From: Hameed Kunkanoor Date: Sun, 14 Jun 2026 13:17:52 +0530 Subject: [PATCH 1/3] feat: add azure-ai-agentserver-activity package (Activity Protocol host) Initial release of the Activity Protocol host package for Azure AI Foundry hosted agents. Provides Starlette-based HTTP endpoints for Activity Protocol traffic with M365 Agents SDK integration. Package (azure-ai-agentserver-activity 1.0.0b1): - ActivityAgentServerHost with POST /activity/messages and /api/messages - Decorator API: @app.activity(type), @app.error for zero-config usage - Custom handler support for full M365 SDK control - Auto-initialization of M365 SDK from environment variables - MSAL auth patches for Foundry container MAIB auth (apply_msal_patches) - Session ID resolution (query param, header, config, UUID fallback) - Activity/session ID sanitization for header injection defense - OpenTelemetry distributed tracing and W3C Baggage propagation - Error-source classification (x-platform-error-source) Samples: - simple_activity_agent (echo bot) - streaming_activity_agent (Azure OpenAI streaming) - cards_activity_agent (Adaptive/Hero/Thumbnail/Receipt cards) - auto_signin_activity_agent (OAuth with Graph/GitHub) - semantic_kernel_activity_agent (SK agent with tools, multi-turn) - suggested_actions_activity_agent (quick-reply buttons) Tests: 26 tests covering routes, session resolution, error classification, tracing, decorator pattern, and ID sanitization edge cases. --- .../CHANGELOG.md | 17 + .../azure-ai-agentserver-activity/LICENSE | 21 + .../azure-ai-agentserver-activity/MANIFEST.in | 8 + .../azure-ai-agentserver-activity/README.md | 82 ++++ .../azure/__init__.py | 1 + .../azure/ai/__init__.py | 1 + .../azure/ai/agentserver/__init__.py | 1 + .../azure/ai/agentserver/activity/__init__.py | 40 ++ .../ai/agentserver/activity/_activity.py | 403 ++++++++++++++++++ .../ai/agentserver/activity/_constants.py | 26 ++ .../ai/agentserver/activity/_m365_bridge.py | 267 ++++++++++++ .../azure/ai/agentserver/activity/_version.py | 5 + .../azure/ai/agentserver/activity/py.typed | 0 .../azure-ai-agentserver-activity/cspell.json | 15 + .../dev_requirements.txt | 7 + .../pyproject.toml | 74 ++++ .../pyrightconfig.json | 11 + .../samples/README.md | 132 ++++++ .../auto_signin_activity_agent.py | 249 +++++++++++ .../requirements.txt | 6 + .../cards_activity_agent.py | 301 +++++++++++++ .../cards_activity_agent/requirements.txt | 5 + .../requirements.txt | 6 + .../semantic_kernel_activity_agent.py | 279 ++++++++++++ .../simple_activity_agent/requirements.txt | 5 + .../simple_activity_agent.py | 109 +++++ .../streaming_activity_agent/requirements.txt | 6 + .../streaming_activity_agent.py | 172 ++++++++ .../requirements.txt | 5 + .../suggested_actions_activity_agent.py | 134 ++++++ .../tests/conftest.py | 27 ++ .../tests/test_decorator_pattern.py | 35 ++ .../tests/test_error_source_classification.py | 50 +++ .../tests/test_id_sanitization.py | 90 ++++ .../tests/test_sanitize_id.py | 61 +++ .../tests/test_server_routes.py | 83 ++++ .../tests/test_session_headers.py | 46 ++ .../tests/test_tracing.py | 96 +++++ sdk/agentserver/ci.yml | 2 + sdk/agentserver/tests.yml | 2 +- 40 files changed, 2879 insertions(+), 1 deletion(-) create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/CHANGELOG.md create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/LICENSE create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/MANIFEST.in create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/README.md create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/azure/__init__.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/azure/ai/__init__.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/__init__.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/__init__.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_activity.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_constants.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_m365_bridge.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_version.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/py.typed create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/cspell.json create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/dev_requirements.txt create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/pyproject.toml create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/pyrightconfig.json create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/README.md create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/auto_signin_activity_agent/auto_signin_activity_agent.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/auto_signin_activity_agent/requirements.txt create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/cards_activity_agent/cards_activity_agent.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/cards_activity_agent/requirements.txt create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/semantic_kernel_activity_agent/requirements.txt create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/semantic_kernel_activity_agent/semantic_kernel_activity_agent.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/simple_activity_agent/requirements.txt create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/simple_activity_agent/simple_activity_agent.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/streaming_activity_agent/requirements.txt create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/streaming_activity_agent/streaming_activity_agent.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/suggested_actions_activity_agent/requirements.txt create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/suggested_actions_activity_agent/suggested_actions_activity_agent.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/tests/conftest.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/tests/test_decorator_pattern.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/tests/test_error_source_classification.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/tests/test_id_sanitization.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/tests/test_sanitize_id.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/tests/test_server_routes.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/tests/test_session_headers.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/tests/test_tracing.py 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..c88656decfce --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_activity.py @@ -0,0 +1,403 @@ +# --------------------------------------------------------- +# 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: + 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 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/README.md b/sdk/agentserver/azure-ai-agentserver-activity/samples/README.md new file mode 100644 index 000000000000..e11ed576ddfa --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/README.md @@ -0,0 +1,132 @@ +# Activity Protocol Samples + +Samples for the `azure-ai-agentserver-activity` package, demonstrating the Activity protocol +for Foundry hosted agents. Each sample is based on a corresponding sample from the +[Microsoft 365 Agents SDK](https://github.com/microsoft/Agents/tree/main/samples/python). + +## Samples + +| Sample | Based on | What it demonstrates | Pattern | +| --- | --- | --- | --- | +| [`simple_activity_agent`](#simple_activity_agent) | [quickstart](https://github.com/microsoft/Agents/tree/main/samples/python/quickstart) | Echo bot with welcome, invoke, installationUpdate, error handling | Zero-config decorator | +| [`streaming_activity_agent`](#streaming_activity_agent) | [azureai-streaming](https://github.com/microsoft/Agents/tree/main/samples/python/azureai-streaming) | Azure OpenAI streaming via `context.streaming_response` | Zero-config decorator | +| [`cards_activity_agent`](#cards_activity_agent) | [cards](https://github.com/microsoft/Agents/tree/main/samples/python/cards) | Adaptive Cards, Hero Cards, Thumbnail Cards, Receipt Cards | Zero-config decorator | +| [`auto_signin_activity_agent`](#auto_signin_activity_agent) | [auto-signin](https://github.com/microsoft/Agents/tree/main/samples/python/auto-signin) | OAuth auto sign-in with Graph and GitHub providers | Handler (for `auth_handlers`) | +| [`semantic_kernel_activity_agent`](#semantic_kernel_activity_agent) | [semantic-kernel-multiturn](https://github.com/microsoft/Agents/tree/main/samples/python/semantic-kernel-multiturn) | Semantic Kernel agent with tools, multi-turn, streaming | Zero-config decorator | +| [`suggested_actions_activity_agent`](#suggested_actions_activity_agent) | [suggested-actions](https://github.com/microsoft/BotBuilder-Samples/tree/main/samples/python/08.suggested-actions) | Quick-reply buttons that disappear after tap | Zero-config decorator | + +### Usage patterns + +- **Zero-config decorator** — The simplest pattern. `ActivityAgentServerHost` auto-initializes the + M365 Agents SDK from environment variables. You write only handler logic: + ```python + app = ActivityAgentServerHost() + + @app.activity("message") + async def on_message(context, state): + await context.send_activity(f"Echo: {context.activity.text}") + + app.run() + ``` + +- **Handler** — Full control. You create the M365 SDK `AgentApplication` yourself and pass a custom + handler. Required for M365-specific features like `auth_handlers` or regex-matched `@AGENT_APP.message()`: + ```python + AGENT_APP = AgentApplication[TurnState](storage=..., adapter=..., ...) + + @AGENT_APP.message("/me", auth_handlers=["GRAPH"]) + async def on_me(context, state): ... + + async def handle(request): + activity = Activity.model_validate(request.state.activity) + await ADAPTER.process_activity(claims, activity, AGENT_APP.on_turn) + return Response(status_code=202) + + app = ActivityAgentServerHost(handler=handle) + ``` + +## simple_activity_agent + +The simplest activity protocol agent. Echoes messages back, welcomes new members, +handles installation events, and processes invoke activities. Zero SDK wiring needed. + +- `@app.activity("conversationUpdate")` — welcome new members +- `@app.activity("message")` — echo user text +- `@app.activity("invoke")` — handle adaptive card actions / task modules +- `@app.activity("installationUpdate")` — add/remove events +- `@app.error` — error handler + +## streaming_activity_agent + +Streams Azure OpenAI completions token-by-token to the user in Teams using +the M365 SDK's `context.streaming_response`: + +- `context.streaming_response.set_generated_by_ai_label(True)` — AI content label +- `context.streaming_response.set_feedback_loop(True)` — enable thumbs up/down +- `context.streaming_response.queue_informative_update(...)` — status text +- `context.streaming_response.queue_text_chunk(...)` — streamed tokens +- `context.streaming_response.end_stream()` — completion signal + +**Requires:** `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_MODEL` + +## cards_activity_agent + +Shows rich card types to enhance conversation design: + +- **Adaptive Card** — interactive form with text input, dropdown, and submit button +- **Hero Card** — large image, title, subtitle, action buttons +- **Thumbnail Card** — compact layout with thumbnail image +- **Receipt Card** — line items, totals, and action buttons + +Handles Adaptive Card submit actions via `@app.activity("invoke")`. + +## auto_signin_activity_agent + +OAuth auto sign-in with multiple providers. Uses the handler pattern because +`auth_handlers` is an `AgentApplication` feature. + +- `@AGENT_APP.message("/me", auth_handlers=["GRAPH"])` — Graph profile +- `@AGENT_APP.message("/prs", auth_handlers=["GITHUB"])` — GitHub repos +- `@AGENT_APP.message("/status")` — token status +- `@AGENT_APP.message("/logout")` — sign out + +**Requires:** OAuth connection names in environment variables (see sample docstring). + +## semantic_kernel_activity_agent + +Multi-turn weather agent built with Semantic Kernel. Demonstrates function calling +with custom plugins and streaming responses. + +- `DateTimePlugin` — provides current date/time +- `WeatherPlugin` — simulated weather data (replace with real API) +- `ChatCompletionAgent` with `FunctionChoiceBehavior.Auto()` +- Multi-turn conversation with in-memory session history +- Streaming via `context.streaming_response` + +**Requires:** `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_MODEL` + +## suggested_actions_activity_agent + +Quick-reply buttons that disappear after the user taps one. Based on +the BotBuilder `08.suggested-actions` sample. + +- `SuggestedActions` with `CardAction(type="imBack", ...)` buttons +- Buttons disappear after selection (unlike card buttons which persist) +- Re-prompts with new suggestions after each response +- Great for guided menus, confirmations, and quick choices + +## Running + +```bash +# Set M365 SDK env vars (auto-injected in Foundry hosted containers) +export CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +export CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHTYPE=UserManagedIdentity +export CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHORITY=https://login.microsoftonline.com/ +export CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= + +# Install dependencies +pip install -r /requirements.txt + +# Run +python /.py +``` diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/auto_signin_activity_agent/auto_signin_activity_agent.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/auto_signin_activity_agent/auto_signin_activity_agent.py new file mode 100644 index 000000000000..1013808ea22d --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/auto_signin_activity_agent/auto_signin_activity_agent.py @@ -0,0 +1,249 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Auto Sign-In Agent — Activity Protocol with OAuth. + +Demonstrates OAuth auto sign-in with multiple providers (Graph and GitHub) +using the M365 Agents SDK's ``auth_handlers`` feature. + +This sample uses the **handler pattern** because ``auth_handlers`` is +an ``AgentApplication`` feature that requires direct access to the +M365 SDK ``AgentApplication`` instance. + +Available commands: + /me — Sign in with Graph and show profile info + /prs — Sign in with GitHub and list recent PRs + /status — Show current token status + /logout — Sign out of all providers + +Required environment variables: + + # M365 Agents SDK (auto-injected by Foundry) + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHTYPE + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHORITY + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID + + # OAuth handler — Graph + AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GRAPH__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME= + + # OAuth handler — GitHub + AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GITHUB__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME= + + # Auto sign-in + AGENTAPPLICATION__USERAUTHORIZATION__AUTOSIGNIN=true + +Usage:: + + python auto_signin_activity_agent.py +""" + +import logging +import sys +import traceback +from os import environ + +from starlette.responses import JSONResponse, Response + +from azure.ai.agentserver.activity import ActivityAgentServerHost + +from microsoft_agents.activity import Activity, ActivityTypes, 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, +) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s | %(message)s", +) +logger = logging.getLogger(__name__) + +# Enable M365 SDK logging +ms_agents_logger = logging.getLogger("microsoft_agents") +ms_agents_logger.addHandler(logging.StreamHandler()) +ms_agents_logger.setLevel(logging.INFO) + + +# ── M365 SDK setup (handler pattern for auth_handlers) ─────────── + +# Apply MSAL patches BEFORE creating MsalConnectionManager. +# In Foundry hosted containers (AUTHTYPE=UserManagedIdentity), the stock +# MsalAuth uses ManagedIdentityClient which doesn't support fmi_path. +# This patch replaces get_agentic_application_token with DefaultAzureCredential. +# The zero-config decorator samples get this for free via the M365 bridge. +from azure.ai.agentserver.activity import apply_msal_patches +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, +) + + +# ── Activity handlers ──────────────────────────────────────────── + + +@AGENT_APP.conversation_update("membersAdded") +async def on_members_added(context: TurnContext, _state: TurnState): + """Welcome new members with auth status info.""" + await context.send_activity( + "Welcome to the Auto Sign-In sample!\n\n" + "**Commands:**\n" + "- **/me** — Sign in with Graph and show your profile\n" + "- **/prs** — Sign in with GitHub and list your recent PRs\n" + "- **/status** — Show current token status\n" + "- **/logout** — Sign out of all providers" + ) + return True + + +@AGENT_APP.message("/me", auth_handlers=["GRAPH"]) +async def on_me(context: TurnContext, state: TurnState): + """Show user profile from Microsoft Graph. + + The ``auth_handlers=["GRAPH"]`` parameter triggers auto sign-in + for the GRAPH OAuth connection before this handler runs. + The token is available in ``state.temp.auth_tokens["GRAPH"]``. + """ + import aiohttp + + token = state.temp.auth_tokens.get("GRAPH") + if not token: + await context.send_activity("No Graph token available. Try again.") + return + + async with aiohttp.ClientSession() as session: + headers = {"Authorization": f"Bearer {token}"} + async with session.get("https://graph.microsoft.com/v1.0/me", headers=headers) as resp: + if resp.status == 200: + profile = await resp.json() + name = profile.get("displayName", "Unknown") + email = profile.get("mail", profile.get("userPrincipalName", "Unknown")) + job = profile.get("jobTitle", "N/A") + await context.send_activity( + f"**Your Profile (Graph)**\n\n" + f"- **Name:** {name}\n" + f"- **Email:** {email}\n" + f"- **Job Title:** {job}" + ) + else: + await context.send_activity(f"Graph API returned {resp.status}.") + + +@AGENT_APP.message("/prs", auth_handlers=["GITHUB"]) +async def on_prs(context: TurnContext, state: TurnState): + """List recent GitHub PRs. + + The ``auth_handlers=["GITHUB"]`` parameter triggers auto sign-in + for the GITHUB OAuth connection before this handler runs. + """ + import aiohttp + + token = state.temp.auth_tokens.get("GITHUB") + if not token: + await context.send_activity("No GitHub token available. Try again.") + return + + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + } + async with session.get( + "https://api.github.com/user/repos?sort=updated&per_page=5", + headers=headers, + ) as resp: + if resp.status == 200: + repos = await resp.json() + lines = ["**Your Recent GitHub Repos:**\n"] + for repo in repos: + lines.append(f"- [{repo['full_name']}]({repo['html_url']})") + await context.send_activity("\n".join(lines)) + else: + await context.send_activity(f"GitHub API returned {resp.status}.") + + +@AGENT_APP.message("/status") +async def on_status(context: TurnContext, state: TurnState): + """Show current OAuth token status.""" + tokens = getattr(getattr(state, "temp", None), "auth_tokens", {}) or {} + if tokens: + status_lines = ["**Token Status:**\n"] + for name, token in tokens.items(): + status_lines.append(f"- **{name}:** {'Available' if token else 'Not available'}") + await context.send_activity("\n".join(status_lines)) + else: + await context.send_activity("No tokens are currently cached. Use **/me** or **/prs** to sign in.") + + +@AGENT_APP.message("/logout") +async def on_logout(context: TurnContext, state: TurnState): + """Sign out of all OAuth connections.""" + await context.send_activity("You have been signed out. Use **/me** or **/prs** to sign in again.") + + +@AGENT_APP.activity("message") +async def on_message(context: TurnContext, _state: TurnState): + """Default message handler.""" + await context.send_activity( + f"You said: {context.activity.text}\n\n" + "Try **/me**, **/prs**, **/status**, or **/logout**." + ) + + +@AGENT_APP.error +async def on_error(context: TurnContext, error: Exception): + """Handle unhandled errors.""" + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + await context.send_activity("The agent encountered an error or bug.") + + +# ── Foundry host with custom handler ───────────────────────────── + + +async def handle(request) -> Response: + """Bridge to M365 SDK — parses activity and delegates to AGENT_APP.""" + activity = Activity.model_validate(request.state.activity) + + 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) + + 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() diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/auto_signin_activity_agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/auto_signin_activity_agent/requirements.txt new file mode 100644 index 000000000000..619fb960075d --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/auto_signin_activity_agent/requirements.txt @@ -0,0 +1,6 @@ +azure-ai-agentserver-activity +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-activity +azure-identity +aiohttp diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/cards_activity_agent/cards_activity_agent.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/cards_activity_agent/cards_activity_agent.py new file mode 100644 index 000000000000..5c41298a18e7 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/cards_activity_agent/cards_activity_agent.py @@ -0,0 +1,301 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Cards Agent — Activity Protocol with rich card types. + +Demonstrates using rich cards (Adaptive Cards, Hero Cards, etc.) to +enhance conversation design. + +Available commands: + 1 — Adaptive Card (interactive form with input and submit) + 2 — Hero Card (title, subtitle, image, buttons) + 3 — Thumbnail Card (compact card with thumbnail image) + 4 — Receipt Card (receipt with items and totals) + help — Show this menu + +Architecture:: + + ActivityAgentServerHost (Foundry contract) + └── M365 bridge (auto-init) + └── TurnContext → handlers below + └── context.send_activity(MessageFactory.attachment(...)) + +Required environment variables (auto-injected in Foundry hosted containers): + + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHTYPE + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHORITY + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID + +Usage:: + + python cards_activity_agent.py +""" + +import json +import logging +import sys +import traceback + +from azure.ai.agentserver.activity import ActivityAgentServerHost + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s | %(message)s", +) +logger = logging.getLogger(__name__) + +app = ActivityAgentServerHost() + + +# ── Card definitions ───────────────────────────────────────────── + + +ADAPTIVE_CARD = { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "Activity Protocol — Adaptive Card Sample", + "weight": "Bolder", + "size": "Medium", + }, + { + "type": "TextBlock", + "text": "This is an interactive Adaptive Card. Fill in the form and submit!", + "wrap": True, + }, + { + "type": "Input.Text", + "id": "userInput", + "placeholder": "Type something here...", + "label": "Your message", + "isRequired": True, + }, + { + "type": "Input.ChoiceSet", + "id": "colorChoice", + "label": "Pick a color", + "choices": [ + {"title": "Red", "value": "red"}, + {"title": "Blue", "value": "blue"}, + {"title": "Green", "value": "green"}, + ], + }, + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Submit", + "data": {"action": "adaptiveCardSubmit"}, + } + ], +} + + +def _make_hero_card(): + """Create a Hero Card attachment.""" + return { + "contentType": "application/vnd.microsoft.card.hero", + "content": { + "title": "Activity Protocol Hero Card", + "subtitle": "A hero card with an image and action buttons", + "text": ( + "Hero cards are great for presenting key information with " + "a large image, title, and action buttons." + ), + "images": [ + { + "url": "https://learn.microsoft.com/en-us/azure/ai-services/media/azure-ai-services.png", + "alt": "Azure AI Services", + } + ], + "buttons": [ + { + "type": "openUrl", + "title": "Azure AI Documentation", + "value": "https://learn.microsoft.com/azure/ai-services/", + }, + { + "type": "imBack", + "title": "Say hello", + "value": "hello", + }, + ], + }, + } + + +def _make_thumbnail_card(): + """Create a Thumbnail Card attachment.""" + return { + "contentType": "application/vnd.microsoft.card.thumbnail", + "content": { + "title": "Activity Protocol Thumbnail Card", + "subtitle": "Compact card with thumbnail image", + "text": ( + "Thumbnail cards are similar to hero cards but use " + "a smaller image, good for list-style layouts." + ), + "images": [ + { + "url": "https://learn.microsoft.com/en-us/azure/ai-services/media/azure-ai-services.png", + "alt": "Azure AI", + } + ], + "buttons": [ + { + "type": "openUrl", + "title": "Learn More", + "value": "https://learn.microsoft.com/azure/ai-services/", + } + ], + }, + } + + +def _make_receipt_card(): + """Create a Receipt Card attachment.""" + return { + "contentType": "application/vnd.microsoft.card.receipt", + "content": { + "title": "Activity Protocol Receipt", + "facts": [ + {"key": "Order Number", "value": "AP-2026-001"}, + {"key": "Payment Method", "value": "Visa **** 1234"}, + ], + "items": [ + { + "title": "Azure AI Agent Hosting", + "subtitle": "Foundry Hosted Agent", + "price": "$0.00", + "quantity": "1", + }, + { + "title": "Activity Protocol SDK", + "subtitle": "azure-ai-agentserver-activity", + "price": "$0.00", + "quantity": "1", + }, + ], + "total": "$0.00", + "tax": "$0.00", + "buttons": [ + { + "type": "openUrl", + "title": "View in Azure Portal", + "value": "https://portal.azure.com", + } + ], + }, + } + + +INTRO_TEXT = ( + "**Cards Sample — Activity Protocol**\n\n" + "Type a number to see a card:\n\n" + "- **1** — Adaptive Card (interactive form)\n" + "- **2** — Hero Card (title, image, buttons)\n" + "- **3** — Thumbnail Card (compact layout)\n" + "- **4** — Receipt Card (items and totals)\n" + "- **help** — Show this menu" +) + + +# ── Activity handlers ──────────────────────────────────────────── + + +@app.activity("conversationUpdate") +async def on_members_added(context, state): + """Welcome new members with the card menu.""" + for member in context.activity.members_added or []: + if member.id != context.activity.recipient.id: + await context.send_activity(INTRO_TEXT) + + +@app.activity("message") +async def on_message(context, state): + """Route commands to card builders.""" + from microsoft_agents.activity import Activity + + user_text = (context.activity.text or "").strip().lower() + if not user_text: + return + + if user_text == "1": + # Adaptive Card — sent as an attachment on an Activity + attachment = { + "contentType": "application/vnd.microsoft.card.adaptive", + "content": ADAPTIVE_CARD, + } + reply = Activity( + type="message", + text="", + attachments=[attachment], + ) + await context.send_activity(reply) + + elif user_text == "2": + attachment = _make_hero_card() + reply = Activity(type="message", text="", attachments=[attachment]) + await context.send_activity(reply) + + elif user_text == "3": + attachment = _make_thumbnail_card() + reply = Activity(type="message", text="", attachments=[attachment]) + await context.send_activity(reply) + + elif user_text == "4": + attachment = _make_receipt_card() + reply = Activity(type="message", text="", attachments=[attachment]) + await context.send_activity(reply) + + elif user_text in ("help", "menu", "?"): + await context.send_activity(INTRO_TEXT) + + else: + await context.send_activity( + f"Unknown command: **{user_text}**. Type **help** to see available cards." + ) + + +@app.activity("invoke") +async def on_invoke(context, state): + """Handle Adaptive Card submit actions. + + When a user submits an Adaptive Card form, Teams sends an invoke + activity with the form data in ``context.activity.value``. + """ + from microsoft_agents.activity import Activity, ActivityTypes + + value = context.activity.value or {} + action = value.get("action", "") + + if action == "adaptiveCardSubmit": + user_input = value.get("userInput", "(empty)") + color = value.get("colorChoice", "(none)") + await context.send_activity( + f"**Adaptive Card submitted!**\n\n" + f"- Your message: {user_input}\n" + f"- Color choice: {color}" + ) + + # Always send invoke response + invoke_response = Activity( + type=ActivityTypes.invoke_response, + value={"status": 200, "body": {"message": "ok"}}, + ) + await context.send_activity(invoke_response) + + +@app.error +async def on_error(context, error): + """Handle unhandled errors.""" + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + await context.send_activity("The agent encountered an error or bug.") + + +if __name__ == "__main__": + app.run() diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/cards_activity_agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/cards_activity_agent/requirements.txt new file mode 100644 index 000000000000..060932fccb2f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/cards_activity_agent/requirements.txt @@ -0,0 +1,5 @@ +azure-ai-agentserver-activity +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-activity +azure-identity diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/semantic_kernel_activity_agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/semantic_kernel_activity_agent/requirements.txt new file mode 100644 index 000000000000..74135ccc3022 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/semantic_kernel_activity_agent/requirements.txt @@ -0,0 +1,6 @@ +azure-ai-agentserver-activity +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-activity +azure-identity +semantic-kernel[azure] diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/semantic_kernel_activity_agent/semantic_kernel_activity_agent.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/semantic_kernel_activity_agent/semantic_kernel_activity_agent.py new file mode 100644 index 000000000000..5d03501b2686 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/semantic_kernel_activity_agent/semantic_kernel_activity_agent.py @@ -0,0 +1,279 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Semantic Kernel Agent — Activity Protocol with multi-turn tool use. + +Demonstrates a multi-turn weather agent built with Semantic Kernel, +streaming responses token-by-token to Teams. + +Features: +- Semantic Kernel ChatCompletionAgent with function calling +- Custom weather and datetime plugins +- Multi-turn conversation with session state +- Streaming response via ``context.streaming_response`` + +Architecture:: + + ActivityAgentServerHost (Foundry contract) + └── M365 bridge (auto-init) + └── TurnContext with streaming_response + └── Semantic Kernel agent.invoke() → streamed tokens + +Required environment variables: + + # M365 Agents SDK (auto-injected by Foundry) + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHTYPE + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHORITY + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID + + # Azure OpenAI + AZURE_OPENAI_ENDPOINT # e.g. https://myresource.openai.azure.com/ + AZURE_OPENAI_API_KEY # API key + AZURE_OPENAI_MODEL # e.g. gpt-4o + +Usage:: + + python semantic_kernel_activity_agent.py +""" + +import logging +import sys +import traceback +from datetime import datetime, timezone +from os import environ +from typing import Annotated + +from azure.ai.agentserver.activity import ActivityAgentServerHost + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s | %(message)s", +) +logger = logging.getLogger(__name__) + +app = ActivityAgentServerHost() + + +# ── Semantic Kernel plugins ────────────────────────────────────── +# These are simple example plugins. In production, you'd call real APIs. + +try: + from semantic_kernel.functions import kernel_function +except ImportError: + raise ImportError( + "This sample requires semantic-kernel. " + "Install: pip install semantic-kernel" + ) + + +class DateTimePlugin: + """Plugin providing current date and time.""" + + @kernel_function(name="get_date", description="Get the current date and time.") + def get_date(self) -> Annotated[str, "The current date and time in ISO format."]: + return datetime.now(timezone.utc).isoformat() + + +class WeatherPlugin: + """Plugin providing simulated weather data.""" + + @kernel_function( + name="get_current_weather", + description="Get the current weather for a given location.", + ) + def get_current_weather( + self, + location: Annotated[str, "The city name, e.g. 'Seattle'"], + ) -> Annotated[str, "Current weather information as a JSON string."]: + import json + + # Simulated weather data — replace with real API in production + data = { + "location": location, + "temperature": "72°F / 22°C", + "high": "78°F / 26°C", + "low": "65°F / 18°C", + "humidity": "55%", + "wind": "10 mph NW", + "description": "Partly cloudy", + } + return json.dumps(data) + + @kernel_function( + name="get_weather_forecast", + description="Get a 5-day weather forecast for a given location.", + ) + def get_weather_forecast( + self, + location: Annotated[str, "The city name, e.g. 'Seattle'"], + ) -> Annotated[str, "5-day weather forecast as a JSON string."]: + import json + + # Simulated forecast — replace with real API in production + forecast = [ + {"day": "Monday", "high": "78°F", "low": "65°F", "description": "Partly cloudy"}, + {"day": "Tuesday", "high": "80°F", "low": "67°F", "description": "Sunny"}, + {"day": "Wednesday", "high": "75°F", "low": "62°F", "description": "Light rain"}, + {"day": "Thursday", "high": "72°F", "low": "60°F", "description": "Cloudy"}, + {"day": "Friday", "high": "76°F", "low": "63°F", "description": "Sunny"}, + ] + return json.dumps({"location": location, "forecast": forecast}) + + +# ── Semantic Kernel agent (lazy init) ──────────────────────────── + +_sk_agent = None +_sessions: dict[str, dict] = {} # conversation_id → {"history": [...], "last_access": float} +_MAX_SESSIONS = 100 # Evict oldest sessions when this limit is reached + + +def _get_or_create_session(conversation_id: str) -> list: + """Get or create a session, with TTL-based eviction to prevent memory leaks.""" + import time + + now = time.time() + + # Evict stale sessions (older than 1 hour) or when over limit + if len(_sessions) > _MAX_SESSIONS: + stale = [k for k, v in _sessions.items() if now - v["last_access"] > 3600] + for k in stale: + del _sessions[k] + # If still over limit, remove oldest + if len(_sessions) > _MAX_SESSIONS: + oldest = min(_sessions, key=lambda k: _sessions[k]["last_access"]) + del _sessions[oldest] + + if conversation_id not in _sessions: + _sessions[conversation_id] = {"history": [], "last_access": now} + else: + _sessions[conversation_id]["last_access"] = now + + return _sessions[conversation_id]["history"] + + +def _get_sk_agent(): + """Lazily initialize the Semantic Kernel agent.""" + global _sk_agent + if _sk_agent is not None: + return _sk_agent + + from semantic_kernel import Kernel + from semantic_kernel.agents import ChatCompletionAgent + from semantic_kernel.connectors.ai.open_ai import ( + AzureChatCompletion, + OpenAIPromptExecutionSettings, + ) + from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior + + kernel = Kernel() + kernel.add_plugin(plugin=DateTimePlugin(), plugin_name="datetime") + kernel.add_plugin(plugin=WeatherPlugin(), plugin_name="weather") + + service = AzureChatCompletion( + deployment_name=environ.get("AZURE_OPENAI_MODEL", "gpt-4o"), + endpoint=environ["AZURE_OPENAI_ENDPOINT"], + api_key=environ["AZURE_OPENAI_API_KEY"], + ) + + execution_settings = OpenAIPromptExecutionSettings() + execution_settings.function_choice_behavior = FunctionChoiceBehavior.Auto() + execution_settings.temperature = 0 + + _sk_agent = ChatCompletionAgent( + service=service, + name="WeatherAgent", + instructions=( + "You are a friendly weather assistant. " + "Help users find the current weather or a weather forecast for any city. " + "Use the weather plugin tools to get data. " + "Use the datetime plugin to get the current date. " + "Format responses nicely in Markdown. Use emojis where appropriate!" + ), + kernel=kernel, + ) + return _sk_agent + + +# ── Activity handlers ──────────────────────────────────────────── + + +@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( + "Hello! I'm your weather assistant. 🌤️\n\n" + "Ask me about the weather in any city! For example:\n" + "- *What's the weather in Seattle?*\n" + "- *Give me a forecast for Tokyo*\n" + "- *What's the temperature in London?*" + ) + + +@app.activity("message") +async def on_message(context, state): + """Process user message through Semantic Kernel agent with streaming.""" + from semantic_kernel.agents import ChatHistoryAgentThread + from semantic_kernel.contents import ChatHistory + + user_text = (context.activity.text or "").strip() + if not user_text: + return + + logger.info("SK message: %s", user_text[:100]) + + # Configure streaming metadata + context.streaming_response.set_generated_by_ai_label(True) + context.streaming_response.set_feedback_loop(True) + context.streaming_response.queue_informative_update("Checking the weather...") + + agent = _get_sk_agent() + + # Multi-turn: maintain chat history per conversation + conversation_id = context.activity.conversation.id if context.activity.conversation else "default" + session_history = _get_or_create_session(conversation_id) + + history = ChatHistory() + for msg in session_history: + if msg["role"] == "user": + history.add_user_message(msg["content"]) + else: + history.add_assistant_message(msg["content"]) + history.add_user_message(user_text) + + try: + thread = ChatHistoryAgentThread() + response_text = "" + + async for chat in agent.invoke(history, thread=thread): + content = chat.content.content if hasattr(chat, "content") and hasattr(chat.content, "content") else str(chat) + if content: + context.streaming_response.queue_text_chunk(content) + response_text += content + + # Save to history (keep last 20 turns to avoid unbounded growth) + session_history.append({"role": "user", "content": user_text}) + session_history.append({"role": "assistant", "content": response_text}) + if len(session_history) > 40: + del session_history[:-20] + + except Exception as e: + logger.error("SK agent error: %s", e) + context.streaming_response.queue_text_chunk( + "Sorry, I encountered an error while checking the weather. Please try again." + ) + finally: + await context.streaming_response.end_stream() + + +@app.error +async def on_error(context, error): + """Handle unhandled errors.""" + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + await context.send_activity("The agent encountered an error or bug.") + + +if __name__ == "__main__": + app.run() diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/simple_activity_agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/simple_activity_agent/requirements.txt new file mode 100644 index 000000000000..060932fccb2f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/simple_activity_agent/requirements.txt @@ -0,0 +1,5 @@ +azure-ai-agentserver-activity +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-activity +azure-identity diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/simple_activity_agent/simple_activity_agent.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/simple_activity_agent/simple_activity_agent.py new file mode 100644 index 000000000000..b634c757743b --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/simple_activity_agent/simple_activity_agent.py @@ -0,0 +1,109 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Simple Echo Agent — Activity Protocol. + +The simplest activity protocol agent. Echoes messages back, welcomes +new members, handles installation events, and processes invoke activities. + +The package auto-initializes the M365 Agents SDK from environment variables. +You write only handler logic — no SDK wiring needed. + +Architecture:: + + ActivityAgentServerHost (Foundry contract: headers, tracing, errors) + └── M365 bridge (auto-init AgentApplication from env vars) + └── adapter.process_activity(activity, agent_app.on_turn) + └── TurnContext → handler functions below + └── context.send_activity() → POST to channel serviceUrl + +Required environment variables (auto-injected in Foundry hosted containers): + + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHTYPE + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHORITY + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID + +Usage:: + + python simple_activity_agent.py + + # Deployed to Foundry as a hosted agent, messages arrive via + # POST /activity/messages or POST /api/messages +""" + +import logging +import sys +import traceback + +from azure.ai.agentserver.activity import ActivityAgentServerHost + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s | %(message)s", +) +logger = logging.getLogger(__name__) + +app = ActivityAgentServerHost() + + +# ── Activity handlers ──────────────────────────────────────────── + + +@app.activity("conversationUpdate") +async def on_members_added(context, state): + """Welcome new members when they join the conversation.""" + for member in context.activity.members_added or []: + if member.id != context.activity.recipient.id: + name = getattr(member, "name", None) or "there" + await context.send_activity( + f"Welcome, {name}! I'm an echo agent powered by the Activity Protocol. " + "Type anything and I'll repeat it back." + ) + + +@app.activity("message") +async def on_message(context, state): + """Echo the user's message back.""" + user_text = context.activity.text or "" + if not user_text.strip(): + return + logger.info("Message received: %s", user_text[:100]) + await context.send_activity(f"You said: {user_text}") + + +@app.activity("invoke") +async def on_invoke(context, state): + """Handle invoke activities (adaptive card actions, task modules, etc.). + + Invoke activities require a synchronous response with a status code. + """ + from microsoft_agents.activity import Activity, ActivityTypes + + logger.info("Invoke received: %s", getattr(context.activity, "name", "")) + invoke_response = Activity( + type=ActivityTypes.invoke_response, + value={"status": 200}, + ) + await context.send_activity(invoke_response) + + +@app.activity("installationUpdate") +async def on_installation_update(context, state): + """Handle agent installation/uninstallation events.""" + action = getattr(context.activity, "action", None) + if action == "add": + await context.send_activity("Thank you for adding me! Type anything to get started.") + elif action == "remove": + await context.send_activity("Goodbye!") + + +@app.error +async def on_error(context, error): + """Handle unhandled errors.""" + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + await context.send_activity("The agent encountered an error or bug.") + + +if __name__ == "__main__": + app.run() diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/streaming_activity_agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/streaming_activity_agent/requirements.txt new file mode 100644 index 000000000000..385bd85611bb --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/streaming_activity_agent/requirements.txt @@ -0,0 +1,6 @@ +azure-ai-agentserver-activity +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-activity +azure-identity +openai diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/streaming_activity_agent/streaming_activity_agent.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/streaming_activity_agent/streaming_activity_agent.py new file mode 100644 index 000000000000..0306d779fa67 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/streaming_activity_agent/streaming_activity_agent.py @@ -0,0 +1,172 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Streaming Agent — Activity Protocol with Azure OpenAI. + +Demonstrates the M365 SDK's streaming response support via +``context.streaming_response``. Streams Azure OpenAI completions +token-by-token to the user in Teams. + +The agent uses ``streaming_response`` to send: +- Informative updates ("Thinking...") +- Streamed text chunks (real Azure OpenAI token-by-token output) +- AI label and feedback loop metadata +- Final stream end signal + +Architecture:: + + ActivityAgentServerHost (Foundry contract) + └── M365 bridge (auto-init) + └── TurnContext with streaming_response + ├── set_generated_by_ai_label(True) + ├── set_feedback_loop(True) + ├── queue_informative_update("Thinking...") + ├── queue_text_chunk(chunk) (per token from Azure OpenAI) + └── end_stream() (signals completion) + +Required environment variables: + + # M365 Agents SDK (auto-injected by Foundry) + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHTYPE + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHORITY + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID + + # Azure OpenAI configuration + AZURE_OPENAI_ENDPOINT # e.g. https://myresource.openai.azure.com/ + AZURE_OPENAI_API_KEY # API key for Azure OpenAI + AZURE_OPENAI_API_VERSION # e.g. 2025-01-01-preview + AZURE_OPENAI_MODEL # e.g. gpt-4o-mini + +Usage:: + + python streaming_activity_agent.py +""" + +import logging +import sys +import traceback +from os import environ + +from azure.ai.agentserver.activity import ActivityAgentServerHost + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s | %(message)s", +) +logger = logging.getLogger(__name__) + +app = ActivityAgentServerHost() + + +# ── Azure OpenAI client ────────────────────────────────────────── +# Initialized lazily on first message to allow import-time decoration. + +_client = None + + +def _get_openai_client(): + global _client + if _client is None: + from openai import AsyncAzureOpenAI + + _client = AsyncAzureOpenAI( + api_version=environ.get("AZURE_OPENAI_API_VERSION", "2025-01-01-preview"), + azure_endpoint=environ["AZURE_OPENAI_ENDPOINT"], + api_key=environ["AZURE_OPENAI_API_KEY"], + ) + return _client + + +# ── Activity handlers ──────────────────────────────────────────── + + +@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( + "Welcome to the streaming sample! " + "Type any question and I'll stream the response from Azure OpenAI." + ) + + +@app.activity("message") +async def on_message(context, state): + """Stream Azure OpenAI response to the user. + + Uses ``context.streaming_response`` for progressive delivery: + - ``set_generated_by_ai_label(True)`` — marks content as AI-generated + - ``set_feedback_loop(True)`` — enables thumbs up/down in Teams + - ``queue_informative_update(...)`` — shows status text in Teams + - ``queue_text_chunk(...)`` — sends a streamed token + - ``end_stream()`` — signals completion to the channel + + Based on: microsoft/Agents azureai-streaming/src/agent.py + """ + user_text = context.activity.text or "" + if not user_text.strip(): + return + + logger.info("Streaming message: %s", user_text[:100]) + + # Configure streaming metadata + context.streaming_response.set_generated_by_ai_label(True) + context.streaming_response.set_feedback_loop(True) + + # Show informative update while waiting for first token + context.streaming_response.queue_informative_update("Thinking...") + + client = _get_openai_client() + model = environ.get("AZURE_OPENAI_MODEL", "gpt-4o-mini") + + try: + streamed_response = await client.chat.completions.create( + model=model, + messages=[ + { + "role": "system", + "content": ( + "You are a helpful assistant. Keep responses concise " + "and well-formatted using Markdown." + ), + }, + {"role": "user", "content": user_text}, + ], + stream=True, + ) + + async for chunk in streamed_response: + if chunk.choices and chunk.choices[0].delta.content: + context.streaming_response.queue_text_chunk( + chunk.choices[0].delta.content + ) + except Exception as e: + logger.error("Error during streaming: %s", e) + context.streaming_response.queue_text_chunk( + "An error occurred while generating the response. Please try again." + ) + finally: + await context.streaming_response.end_stream() + + +@app.activity("invoke") +async def on_invoke(context, state): + """Handle invoke activities.""" + from microsoft_agents.activity import Activity, ActivityTypes + + await context.send_activity( + Activity(type=ActivityTypes.invoke_response, value={"status": 200}) + ) + + +@app.error +async def on_error(context, error): + """Handle unhandled errors.""" + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + await context.send_activity("The agent encountered an error or bug.") + + +if __name__ == "__main__": + app.run() diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/suggested_actions_activity_agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/suggested_actions_activity_agent/requirements.txt new file mode 100644 index 000000000000..060932fccb2f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/suggested_actions_activity_agent/requirements.txt @@ -0,0 +1,5 @@ +azure-ai-agentserver-activity +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-activity +azure-identity diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/suggested_actions_activity_agent/suggested_actions_activity_agent.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/suggested_actions_activity_agent/suggested_actions_activity_agent.py new file mode 100644 index 000000000000..19aceeac3f14 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/suggested_actions_activity_agent/suggested_actions_activity_agent.py @@ -0,0 +1,134 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Suggested Actions Agent — Activity Protocol with quick-reply buttons. + +Demonstrates using suggested actions to present quick-reply buttons +that disappear after the user taps one. This provides a guided +conversation experience without cluttering the chat with stale buttons. + +Suggested actions differ from card buttons: +- Card buttons remain visible after tapping +- Suggested actions disappear after the user makes a selection +- Best for quick choices, confirmations, or menu options + +Architecture:: + + ActivityAgentServerHost (Foundry contract) + └── M365 bridge (auto-init) + └── TurnContext → handlers below + └── context.send_activity(Activity(suggested_actions=...)) + +Required environment variables (auto-injected in Foundry hosted containers): + + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHTYPE + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHORITY + CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID + +Usage:: + + python suggested_actions_activity_agent.py +""" + +import logging +import sys +import traceback + +from azure.ai.agentserver.activity import ActivityAgentServerHost + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s | %(message)s", +) +logger = logging.getLogger(__name__) + +app = ActivityAgentServerHost() + + +# ── Helpers ────────────────────────────────────────────────────── + + +def _create_reply_with_suggestions(text, choices): + """Create an Activity with suggested actions. + + Suggested actions appear as quick-reply buttons below the message. + They disappear after the user taps one. + + :param text: The message text to display. + :param choices: List of button labels (strings). + :returns: An Activity dict with suggested_actions. + """ + from microsoft_agents.activity import Activity, SuggestedActions, CardAction + + actions = [ + CardAction(type="imBack", title=choice, value=choice) + for choice in choices + ] + + return Activity( + type="message", + text=text, + suggested_actions=SuggestedActions(actions=actions), + ) + + +# ── Color responses ────────────────────────────────────────────── + +COLOR_RESPONSES = { + "red": "Red is the color of passion and energy! 🔴", + "blue": "Blue is the color of calm and trust! 🔵", + "green": "Green is the color of nature and growth! 🟢", + "yellow": "Yellow is the color of sunshine and happiness! 🟡", +} + +COLOR_CHOICES = list(COLOR_RESPONSES.keys()) + + +# ── Activity handlers ──────────────────────────────────────────── + + +@app.activity("conversationUpdate") +async def on_members_added(context, state): + """Welcome new members with suggested action buttons.""" + for member in context.activity.members_added or []: + if member.id != context.activity.recipient.id: + reply = _create_reply_with_suggestions( + "Welcome to the Suggested Actions sample! Pick a color:", + COLOR_CHOICES, + ) + await context.send_activity(reply) + + +@app.activity("message") +async def on_message(context, state): + """Handle color selection and re-prompt with suggestions.""" + user_text = (context.activity.text or "").strip().lower() + if not user_text: + return + + if user_text in COLOR_RESPONSES: + response_text = COLOR_RESPONSES[user_text] + else: + response_text = ( + f"**{user_text}** is not a recognized color. " + "Please pick one of the suggested options below." + ) + + # Always re-prompt with suggested actions so the user can pick again + reply = _create_reply_with_suggestions( + f"{response_text}\n\nPick another color:", + COLOR_CHOICES, + ) + await context.send_activity(reply) + + +@app.error +async def on_error(context, error): + """Handle unhandled errors.""" + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + await context.send_activity("The agent encountered an error or bug.") + + +if __name__ == "__main__": + app.run() 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 "