From 097488f1aa3b98aa2d40ed3d0f58da54ae063b2c Mon Sep 17 00:00:00 2001 From: Shanmukha Pasumarthy Date: Mon, 15 Jun 2026 13:51:34 +0530 Subject: [PATCH 1/2] feat(agentserver): add FoundryStorage for Activity Protocol durable state Add a Foundry-backed implementation of the M365 Agents SDK Storage abstraction so Activity Protocol agents can persist conversation/user state and proactive references in Foundry-managed Cosmos storage. - core: async Activity-state REST transport client + settings, error mapping, serializer, and masked logging policy under azure-ai-agentserver-core/storage (read/write/delete batch envelopes; POST /storage/activity/state:read|:write|:delete?api-version=v1) - activity: FoundryStorage(Storage) adapter; ActivityAgentServerHost gains a storage= arg wired through the M365 bridge into Authorization / AgentApplication (falls back to MemoryStorage) - samples: foundry_storage_state_agent (durable state) and foundry_storage_proactive_agent (proactive reference) - tests for transport client and adapter/bridge wiring; docs updated Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure-ai-agentserver-activity/README.md | 12 ++ .../azure/ai/agentserver/activity/__init__.py | 3 +- .../ai/agentserver/activity/_activity.py | 6 + .../agentserver/activity/_foundry_storage.py | 105 ++++++++++++ .../ai/agentserver/activity/_m365_bridge.py | 13 +- .../dev_requirements.txt | 1 + .../pyproject.toml | 3 +- .../samples/README.md | 18 +++ .../foundry_storage_proactive_agent.py | 124 +++++++++++++++ .../requirements.txt | 5 + .../foundry_storage_state_agent.py | 67 ++++++++ .../requirements.txt | 5 + .../tests/test_foundry_storage.py | 149 +++++++++++++++++ .../ai/agentserver/core/storage/__init__.py | 23 +++ .../core/storage/_activity_state_client.py | 125 +++++++++++++++ .../core/storage/_foundry_errors.py | 73 +++++++++ .../core/storage/_foundry_logging_policy.py | 103 ++++++++++++ .../core/storage/_foundry_serializer.py | 53 +++++++ .../core/storage/_foundry_settings.py | 52 ++++++ .../azure-ai-agentserver-core/pyproject.toml | 1 + .../test_foundry_activity_state_client.py | 150 ++++++++++++++++++ 21 files changed, 1086 insertions(+), 5 deletions(-) create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_foundry_storage.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_proactive_agent/foundry_storage_proactive_agent.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_proactive_agent/requirements.txt create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_state_agent/foundry_storage_state_agent.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_state_agent/requirements.txt create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/tests/test_foundry_storage.py create mode 100644 sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/__init__.py create mode 100644 sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_activity_state_client.py create mode 100644 sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_foundry_errors.py create mode 100644 sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_foundry_logging_policy.py create mode 100644 sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_foundry_serializer.py create mode 100644 sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_foundry_settings.py create mode 100644 sdk/agentserver/azure-ai-agentserver-core/tests/test_foundry_activity_state_client.py diff --git a/sdk/agentserver/azure-ai-agentserver-activity/README.md b/sdk/agentserver/azure-ai-agentserver-activity/README.md index 4805e33a2811..f7ebbb19419c 100644 --- a/sdk/agentserver/azure-ai-agentserver-activity/README.md +++ b/sdk/agentserver/azure-ai-agentserver-activity/README.md @@ -42,6 +42,15 @@ async def on_error(context, error): app.run() ``` +**Foundry durable storage** — drop-in durable state for the M365 bridge: + +```python +from azure.ai.agentserver.activity import ActivityAgentServerHost, FoundryStorage + +storage = FoundryStorage() +app = ActivityAgentServerHost(storage=storage) +``` + **Custom handler** — full control over the M365 SDK pipeline: ```python @@ -68,6 +77,7 @@ app.run() ### Public API - `ActivityAgentServerHost` — the host class +- `FoundryStorage` — platform-managed durable storage for M365 conversation, user, and proactive state - `apply_msal_patches()` — patches M365 SDK MSAL auth for Foundry containers (UserManagedIdentity with fmi_path) ## Samples @@ -80,3 +90,5 @@ See [samples/README.md](samples/README.md) for runnable scenarios: - `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 +- `foundry_storage_state_agent` — durable conversation and user state with `FoundryStorage` +- `foundry_storage_proactive_agent` — durable proactive conversation references with `FoundryStorage` 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 index eceab9b77da9..a6a3c9e93a23 100644 --- 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 @@ -33,8 +33,9 @@ async def handle(request): __path__ = __import__("pkgutil").extend_path(__path__, __name__) from ._activity import ActivityAgentServerHost +from ._foundry_storage import FoundryStorage from ._m365_bridge import _apply_msal_patches as apply_msal_patches from ._version import VERSION -__all__ = ["ActivityAgentServerHost", "apply_msal_patches"] +__all__ = ["ActivityAgentServerHost", "FoundryStorage", "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 index c88656decfce..bba685ab16af 100644 --- 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 @@ -147,6 +147,10 @@ async def on_message(context, state): ``Request`` with ``request.state.activity`` set to the parsed activity dict. :type handler: Optional[Callable[[Request], Awaitable[Response]]] + :param storage: Optional M365 Agents SDK storage implementation used by + the built-in bridge. When omitted, the bridge falls back to + ``MemoryStorage``. + :type storage: Optional[Any] """ _INSTRUMENTATION_SCOPE = "Azure.AI.AgentServer.Activity" @@ -155,6 +159,7 @@ def __init__( self, *, handler: Optional[Callable[[Request], Awaitable[Response]]] = None, + storage: Optional[Any] = None, **kwargs: Any, ) -> None: if handler is not None and not inspect.iscoroutinefunction(handler): @@ -184,6 +189,7 @@ def __init__( existing = list(kwargs.pop("routes", None) or []) super().__init__(routes=existing + activity_routes, **kwargs) + self.state.activity_storage = storage # ------------------------------------------------------------------ # Handler decorators diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_foundry_storage.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_foundry_storage.py new file mode 100644 index 000000000000..b781cc71befb --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_foundry_storage.py @@ -0,0 +1,105 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""M365 Agents SDK storage adapter backed by Foundry activity state storage.""" +# pylint: disable=docstring-missing-param,docstring-missing-return,docstring-missing-rtype +# pylint: disable=docstring-keyword-should-match-keyword-only,import-error,no-name-in-module + +from __future__ import annotations + +from typing import Any, Type, TypeVar + +from azure.ai.agentserver.core.storage import FoundryActivityStateClient, FoundryActivityStateSettings +from azure.core.credentials_async import AsyncTokenCredential + +try: + from microsoft_agents.hosting.core import Storage +except ImportError: # pragma: no cover - keeps package importable without optional M365 SDK bits. + class Storage: # type: ignore[no-redef] + """Fallback base class used only when the M365 Agents SDK is not installed.""" + + +StoreItemT = TypeVar("StoreItemT") + + +class FoundryStorage(Storage): + """Durable M365 Agents SDK storage adapter for Foundry-hosted Activity agents.""" + + def __init__( + self, + *, + client: FoundryActivityStateClient | None = None, + credential: AsyncTokenCredential | None = None, + settings: FoundryActivityStateSettings | None = None, + ) -> None: + self._credential = credential + self._owns_credential = False + + if client is None: + if self._credential is None: + try: + from azure.identity.aio import DefaultAzureCredential + except ImportError as exc: # pragma: no cover + raise ImportError( + "FoundryStorage requires azure-identity when no credential is supplied. " + "Install azure-identity or pass an async credential." + ) from exc + self._credential = DefaultAzureCredential() + self._owns_credential = True + client = FoundryActivityStateClient(credential=self._credential, settings=settings) + self._client = client + + async def aclose(self) -> None: + """Close the underlying Foundry state client and owned credential.""" + await self._client.aclose() + if self._owns_credential and self._credential is not None and hasattr(self._credential, "close"): + await self._credential.close() + + async def __aenter__(self) -> "FoundryStorage": + return self + + async def __aexit__(self, *args: Any) -> None: + await self.aclose() + + async def read( + self, + keys: list[str], + *, + target_cls: Type[StoreItemT] | None = None, + **kwargs: Any, + ) -> dict[str, StoreItemT]: + """Read multiple items from Foundry storage. Missing keys are omitted.""" + _ = kwargs + if not keys: + raise ValueError("Storage.read(): Keys are required when reading.") + if not target_cls: + raise ValueError("Storage.read(): target_cls cannot be None.") + for key in keys: + if key == "": + raise ValueError("FoundryStorage.read(): key cannot be empty") + + raw_items = await self._client.read(list(keys)) + result: dict[str, StoreItemT] = {} + for key, item in raw_items.items(): + result[key] = target_cls.from_json_to_store_item(item.get("value")) # type: ignore[attr-defined] + return result + + async def write(self, changes: dict[str, StoreItemT]) -> None: + """Write multiple items to Foundry storage using last-write-wins upserts.""" + if not changes: + raise ValueError("Storage.write(): Changes are required when writing.") + for key in changes: + if key == "": + raise ValueError("FoundryStorage.write(): key cannot be empty") + + payload = {key: item.store_item_to_json() for key, item in changes.items()} # type: ignore[attr-defined] + await self._client.write(payload) + + async def delete(self, keys: list[str]) -> None: + """Delete multiple items from Foundry storage. Missing keys are ignored.""" + if not keys: + raise ValueError("Storage.delete(): Keys are required when deleting.") + for key in keys: + if key == "": + raise ValueError("FoundryStorage.delete(): key cannot be empty") + + await self._client.delete(list(keys)) 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 index fa13f1a8243f..74a2f1e88c14 100644 --- 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 @@ -90,10 +90,16 @@ async def _get_token_via_dac(self, tenant_id: str, agent_app_instance_id: str) - logger.info("Patched MsalAuth.get_agentic_application_token → DefaultAzureCredential") -def _ensure_m365_initialized(): +def _ensure_m365_initialized(storage: Any = None): """Lazily initialize the M365 Agents SDK from environment variables. Called on first request when decorators are used. Idempotent. + + :param storage: Optional M365 storage implementation. Falls back to + ``MemoryStorage`` when omitted. + :type storage: Any + :return: The initialized AgentApplication and adapter. + :rtype: tuple[Any, Any] """ global _m365_initialized, _adapter, _agent_app, _connection_manager @@ -123,7 +129,7 @@ def _ensure_m365_initialized(): logger.info("Initializing M365 Agents SDK...") config = load_configuration_from_env(os.environ) - storage = MemoryStorage() + storage = storage or MemoryStorage() _connection_manager = MsalConnectionManager(**config) client_factory = RestChannelServiceClientFactory(_connection_manager) _adapter = HttpAdapterBase(channel_service_client_factory=client_factory) @@ -153,7 +159,8 @@ async def create_bridge_handler(request: Request) -> Response: from microsoft_agents.hosting.core import ClaimsIdentity global _lazy_agent_app - agent_app, adapter = _ensure_m365_initialized() + storage = getattr(request.app.state, "activity_storage", None) + agent_app, adapter = _ensure_m365_initialized(storage) # Replay pending decorator registrations onto the real AgentApplication if _lazy_agent_app is not None and not _lazy_agent_app._replayed: diff --git a/sdk/agentserver/azure-ai-agentserver-activity/dev_requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/dev_requirements.txt index 25c128efb198..04231583735d 100644 --- a/sdk/agentserver/azure-ai-agentserver-activity/dev_requirements.txt +++ b/sdk/agentserver/azure-ai-agentserver-activity/dev_requirements.txt @@ -1,6 +1,7 @@ # keep in sync with pyproject.toml#dependency-groups.dev -e ../../../eng/tools/azure-sdk-tools -e ../azure-ai-agentserver-core +azure-identity>=1.17.0 pytest httpx pytest-asyncio diff --git a/sdk/agentserver/azure-ai-agentserver-activity/pyproject.toml b/sdk/agentserver/azure-ai-agentserver-activity/pyproject.toml index c379e8cec091..541c32054af3 100644 --- a/sdk/agentserver/azure-ai-agentserver-activity/pyproject.toml +++ b/sdk/agentserver/azure-ai-agentserver-activity/pyproject.toml @@ -21,7 +21,8 @@ classifiers = [ keywords = ["azure", "azure sdk", "agent", "agentserver", "activity"] dependencies = [ - "azure-ai-agentserver-core>=2.0.0b4", + "azure-ai-agentserver-core>=2.0.0b5", + "azure-identity>=1.17.0", "opentelemetry-api>=1.40.0", ] diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/README.md b/sdk/agentserver/azure-ai-agentserver-activity/samples/README.md index e11ed576ddfa..7e3669e13736 100644 --- a/sdk/agentserver/azure-ai-agentserver-activity/samples/README.md +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/README.md @@ -14,6 +14,8 @@ for Foundry hosted agents. Each sample is based on a corresponding sample from t | [`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 | +| [`foundry_storage_state_agent`](#foundry_storage_state_agent) | N/A | Durable conversation and user state with `FoundryStorage()` | Zero-config decorator | +| [`foundry_storage_proactive_agent`](#foundry_storage_proactive_agent) | N/A | Durable proactive conversation references with `FoundryStorage()` | Handler + proactive | ### Usage patterns @@ -115,6 +117,22 @@ the BotBuilder `08.suggested-actions` sample. - Re-prompts with new suggestions after each response - Great for guided menus, confirmations, and quick choices +## foundry_storage_state_agent + +Shows durable Activity Protocol state using platform-managed storage: + +- `storage = FoundryStorage()` — bearer-authenticated Foundry storage backend +- `ActivityAgentServerHost(storage=storage)` — the M365 bridge uses it for conversation/user state +- `state.conversation` and `state.user` counters survive restarts and scale-out + +## foundry_storage_proactive_agent + +Shows durable proactive references with the M365 handler pattern: + +- `FoundryStorage()` is shared by `AgentApplication`, `Authorization`, and `ProactiveOptions` +- `/subscribe` stores the current conversation reference +- `POST /notify/{conversation_id}` resumes the stored reference and sends a proactive message + ## Running ```bash diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_proactive_agent/foundry_storage_proactive_agent.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_proactive_agent/foundry_storage_proactive_agent.py new file mode 100644 index 000000000000..b1e60a5e4bc8 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_proactive_agent/foundry_storage_proactive_agent.py @@ -0,0 +1,124 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Foundry Storage Proactive Agent — durable proactive conversation references. + +Demonstrates the handler pattern with M365 ``ProactiveOptions``. Users send +``/subscribe`` to store the current conversation reference in ``FoundryStorage``. +An external caller can then POST ``/notify/{conversation_id}`` to resume that +stored conversation and send a proactive notification. + +Usage:: + + python foundry_storage_proactive_agent.py +""" + +import logging +import sys +import traceback +from os import environ + +from starlette.responses import JSONResponse, Response +from starlette.routing import Route + +from azure.ai.agentserver.activity import ActivityAgentServerHost, FoundryStorage, apply_msal_patches +from microsoft_agents.activity import Activity, load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.hosting.core import ( + AgentApplication, + Authorization, + ClaimsIdentity, + HttpAdapterBase, + RestChannelServiceClientFactory, + TurnContext, + TurnState, +) +from microsoft_agents.hosting.core.app.proactive.proactive_options import ProactiveOptions + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s | %(message)s", +) + +apply_msal_patches() + +config = load_configuration_from_env(environ) +STORAGE = FoundryStorage() +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, + proactive=ProactiveOptions(storage=STORAGE), + **config, +) + + +@AGENT_APP.message("/subscribe") +async def on_subscribe(context: TurnContext, _state: TurnState): + """Persist the current conversation reference for future proactive sends.""" + await AGENT_APP.proactive.store_conversation(context) + conversation_id = context.activity.conversation.id + await context.send_activity( + "Stored this conversation in FoundryStorage.\n\n" + f"POST `/notify/{conversation_id}` to send a proactive notification." + ) + + +@AGENT_APP.activity("message") +async def on_message(context: TurnContext, _state: TurnState): + """Default message handler.""" + await context.send_activity("Send **/subscribe** to store this conversation for proactive notifications.") + + +@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.") + + +async def handle_activity(request) -> Response: + """Bridge Activity Protocol requests to the M365 Agents SDK.""" + 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") + invoke_response = await ADAPTER.process_activity(claims, activity, AGENT_APP.on_turn) + + 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) + + +async def notify(request) -> Response: + """Resume a stored conversation reference and send a proactive message.""" + conversation_id = request.path_params["conversation_id"] + + async def send_notification(context: TurnContext, _state: TurnState): + await context.send_activity("Proactive notification sent from a conversation reference in FoundryStorage.") + + try: + await AGENT_APP.proactive.continue_conversation(ADAPTER, conversation_id, send_notification) + except KeyError: + return JSONResponse(status_code=404, content={"error": "Conversation reference not found."}) + return JSONResponse({"sent": True, "conversation_id": conversation_id}) + + +app = ActivityAgentServerHost( + handler=handle_activity, + routes=[Route("/notify/{conversation_id}", notify, methods=["POST"])], +) + +if __name__ == "__main__": + app.run() diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_proactive_agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_proactive_agent/requirements.txt new file mode 100644 index 000000000000..060932fccb2f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_proactive_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/foundry_storage_state_agent/foundry_storage_state_agent.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_state_agent/foundry_storage_state_agent.py new file mode 100644 index 000000000000..a4c500f68d4d --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_state_agent/foundry_storage_state_agent.py @@ -0,0 +1,67 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Foundry Storage State Agent — Activity Protocol with durable state. + +Demonstrates the zero-config decorator pattern with platform-managed durable +storage. Conversation and user counters are persisted by the M365 Agents SDK +through ``FoundryStorage`` after each turn. + +Usage:: + + python foundry_storage_state_agent.py +""" + +import logging +import sys +import traceback + +from azure.ai.agentserver.activity import ActivityAgentServerHost, FoundryStorage + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s | %(message)s", +) + +storage = FoundryStorage() +app = ActivityAgentServerHost(storage=storage) + + +@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 persist conversation and user state with FoundryStorage.\n\n" + "Send any message to increment the durable counters." + ) + + +@app.activity("message") +async def on_message(context, state): + """Increment durable conversation and user counters.""" + conversation_count = state.conversation.get_value("message_count", lambda: 0) + user_count = state.user.get_value("message_count", lambda: 0) + + conversation_count += 1 + user_count += 1 + state.conversation.set_value("message_count", conversation_count) + state.user.set_value("message_count", user_count) + + await context.send_activity( + "FoundryStorage persisted this turn.\n\n" + f"- Conversation messages: **{conversation_count}**\n" + f"- Messages from you: **{user_count}**" + ) + + +@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/foundry_storage_state_agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_state_agent/requirements.txt new file mode 100644 index 000000000000..060932fccb2f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_state_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/tests/test_foundry_storage.py b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_foundry_storage.py new file mode 100644 index 000000000000..0edd2a383a18 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_foundry_storage.py @@ -0,0 +1,149 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Unit tests for the M365 FoundryStorage adapter and host storage wiring.""" + +from __future__ import annotations + +import sys +import types +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from azure.ai.agentserver.activity import ActivityAgentServerHost, FoundryStorage +from azure.ai.agentserver.activity import _m365_bridge + + +class _TestStoreItem: + def __init__(self, value: dict[str, Any]) -> None: + self.value = value + + def store_item_to_json(self) -> dict[str, Any]: + return self.value + + @staticmethod + def from_json_to_store_item(json_data: dict[str, Any]) -> "_TestStoreItem": + return _TestStoreItem(json_data) + + +def _make_storage(client: Any) -> FoundryStorage: + return FoundryStorage(client=client) + + +@pytest.mark.asyncio +async def test_foundry_storage_read_deserializes_store_items_and_omits_missing() -> None: + client = AsyncMock() + client.read = AsyncMock(return_value={"present": {"value": {"count": 3}, "etag": "e1"}}) + storage = _make_storage(client) + + result = await storage.read(["present", "missing"], target_cls=_TestStoreItem) + + assert list(result) == ["present"] + assert result["present"].value == {"count": 3} + client.read.assert_awaited_once_with(["present", "missing"]) + + +@pytest.mark.asyncio +async def test_foundry_storage_write_serializes_store_items_for_lww_upsert() -> None: + client = AsyncMock() + storage = _make_storage(client) + + await storage.write({"k": _TestStoreItem({"turn": 4})}) + + client.write.assert_awaited_once_with({"k": {"turn": 4}}) + + +@pytest.mark.asyncio +async def test_foundry_storage_delete_forwards_keys() -> None: + client = AsyncMock() + storage = _make_storage(client) + + await storage.delete(["a", "b"]) + + client.delete.assert_awaited_once_with(["a", "b"]) + + +@pytest.mark.asyncio +async def test_foundry_storage_validates_like_m365_storage() -> None: + storage = _make_storage(AsyncMock()) + + with pytest.raises(ValueError, match="Keys are required"): + await storage.read([], target_cls=_TestStoreItem) + with pytest.raises(ValueError, match="target_cls cannot be None"): + await storage.read(["k"]) + with pytest.raises(ValueError, match="key cannot be empty"): + await storage.write({"": _TestStoreItem({})}) + with pytest.raises(ValueError, match="Keys are required"): + await storage.delete([]) + + +def test_activity_host_stores_storage_for_bridge() -> None: + storage = object() + app = ActivityAgentServerHost(storage=storage, configure_observability=None) + + assert app.state.activity_storage is storage + + +def test_m365_bridge_uses_supplied_storage(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + activity_mod = types.ModuleType("microsoft_agents.activity") + activity_mod.Activity = object + activity_mod.load_configuration_from_env = lambda _env: {"bot_app_id": "app"} + + msal_mod = types.ModuleType("microsoft_agents.authentication.msal") + + class FakeMsalConnectionManager: + def __init__(self, **kwargs: Any) -> None: + captured["config"] = kwargs + + msal_mod.MsalConnectionManager = FakeMsalConnectionManager + + hosting_mod = types.ModuleType("microsoft_agents.hosting.core") + + class FakeMemoryStorage: + pass + + class FakeHttpAdapterBase: + def __init__(self, **kwargs: Any) -> None: + captured["adapter_kwargs"] = kwargs + + class FakeRestChannelServiceClientFactory: + def __init__(self, connection_manager: Any) -> None: + captured["connection_manager"] = connection_manager + + class FakeAuthorization: + def __init__(self, storage: Any, connection_manager: Any, **kwargs: Any) -> None: + captured["auth_storage"] = storage + captured["auth_connection_manager"] = connection_manager + + class FakeAgentApplication: + def __init__(self, **kwargs: Any) -> None: + captured["app_storage"] = kwargs["storage"] + captured["app_authorization"] = kwargs["authorization"] + + @classmethod + def __class_getitem__(cls, _item: Any) -> type["FakeAgentApplication"]: + return cls + + hosting_mod.AgentApplication = FakeAgentApplication + hosting_mod.Authorization = FakeAuthorization + hosting_mod.HttpAdapterBase = FakeHttpAdapterBase + hosting_mod.MemoryStorage = FakeMemoryStorage + hosting_mod.RestChannelServiceClientFactory = FakeRestChannelServiceClientFactory + hosting_mod.TurnState = object + + monkeypatch.setitem(sys.modules, "microsoft_agents.activity", activity_mod) + monkeypatch.setitem(sys.modules, "microsoft_agents.authentication.msal", msal_mod) + monkeypatch.setitem(sys.modules, "microsoft_agents.hosting.core", hosting_mod) + + supplied_storage = object() + _m365_bridge._reset_for_testing() + try: + _m365_bridge._ensure_m365_initialized(supplied_storage) + finally: + _m365_bridge._reset_for_testing() + + assert captured["auth_storage"] is supplied_storage + assert captured["app_storage"] is supplied_storage diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/__init__.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/__init__.py new file mode 100644 index 000000000000..aa78e5a3a9e8 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/__init__.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Foundry-backed storage clients used by AgentServer protocol packages.""" + +from ._activity_state_client import FoundryActivityStateClient +from ._foundry_errors import ( + FoundryApiError, + FoundryBadRequestError, + FoundryResourceNotFoundError, + FoundryStorageError, + raise_for_storage_error, +) +from ._foundry_settings import FoundryActivityStateSettings + +__all__ = [ + "FoundryActivityStateClient", + "FoundryActivityStateSettings", + "FoundryApiError", + "FoundryBadRequestError", + "FoundryResourceNotFoundError", + "FoundryStorageError", + "raise_for_storage_error", +] diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_activity_state_client.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_activity_state_client.py new file mode 100644 index 000000000000..ad2b0274e3e9 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_activity_state_client.py @@ -0,0 +1,125 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""HTTP-backed Foundry activity state storage client.""" +# pylint: disable=docstring-missing-param,docstring-missing-return,docstring-missing-rtype +# pylint: disable=client-accepts-api-version-keyword + +from __future__ import annotations + +from typing import Any, Callable + +from azure.core import AsyncPipelineClient +from azure.core.credentials_async import AsyncTokenCredential +from azure.core.exceptions import ServiceRequestError, ServiceResponseError +from azure.core.pipeline import PipelineRequest, policies +from azure.core.pipeline.policies import SansIOHTTPPolicy +from azure.core.rest import HttpRequest + +from azure.ai.agentserver.core._platform_headers import PLATFORM_ERROR_TAG +from azure.ai.agentserver.core._version import VERSION + +from ._foundry_errors import raise_for_storage_error +from ._foundry_logging_policy import FoundryStorageLoggingPolicy +from ._foundry_serializer import ( + deserialize_read_response, + deserialize_write_response, + serialize_delete_request, + serialize_read_request, + serialize_write_request, +) +from ._foundry_settings import FoundryActivityStateSettings + +_FOUNDRY_TOKEN_SCOPE = "https://ai.azure.com/.default" +_JSON_CONTENT_TYPE = "application/json; charset=utf-8" + + +class _ServerVersionUserAgentPolicy(SansIOHTTPPolicy): # type: ignore[type-arg] + """Pipeline policy that sets the ``User-Agent`` header lazily from a callback.""" + + def __init__(self, get_server_version: Callable[[], str]) -> None: + super().__init__() + self._get_server_version = get_server_version + + def on_request(self, request: PipelineRequest) -> None: # type: ignore[type-arg] + """Set the ``User-Agent`` header before the request is sent.""" + request.http_request.headers["User-Agent"] = self._get_server_version() + + +class FoundryActivityStateClient: + """HTTP client for Foundry-managed Activity Protocol state storage.""" + + def __init__( + self, + credential: AsyncTokenCredential, + settings: FoundryActivityStateSettings | None = None, + get_server_version: Callable[[], str] | None = None, + *, + api_version: str = "v1", + **kwargs: Any, + ) -> None: + if settings is not None and settings.api_version != api_version: + raise ValueError("api_version must match settings.api_version when both are supplied") + self._settings = settings or FoundryActivityStateSettings.from_env(api_version=api_version) + + ua_policy: policies.UserAgentPolicy | _ServerVersionUserAgentPolicy + if get_server_version is not None: + ua_policy = _ServerVersionUserAgentPolicy(get_server_version) + else: + ua_policy = policies.UserAgentPolicy(sdk_moniker=f"ai-agentserver-core/{VERSION}") + + self._client: AsyncPipelineClient = AsyncPipelineClient( + base_url=self._settings.storage_base_url, + policies=[ + policies.RequestIdPolicy(), + policies.HeadersPolicy(), + ua_policy, + policies.AsyncRetryPolicy(), + policies.AsyncBearerTokenCredentialPolicy(credential, _FOUNDRY_TOKEN_SCOPE), + FoundryStorageLoggingPolicy(), + policies.DistributedTracingPolicy(), + ], + **kwargs, + ) + + async def aclose(self) -> None: + """Close the underlying HTTP pipeline client.""" + await self._client.close() + + async def __aenter__(self) -> "FoundryActivityStateClient": + return self + + async def __aexit__(self, *args: Any) -> None: + await self.aclose() + + async def _send_storage_request(self, request: HttpRequest) -> Any: + """Send an HTTP request to the Foundry storage API.""" + try: + http_resp = await self._client.send_request(request) + except (ServiceRequestError, ServiceResponseError, OSError) as exc: + setattr(exc, PLATFORM_ERROR_TAG, True) + raise + raise_for_storage_error(http_resp) + return http_resp + + async def read(self, keys: list[str]) -> dict[str, dict[str, Any]]: + """Read multiple Activity state records by raw M365 storage key.""" + body = serialize_read_request(keys) + url = self._settings.build_url("activity/state:read") + request = HttpRequest("POST", url, content=body, headers={"Content-Type": _JSON_CONTENT_TYPE}) + http_resp = await self._send_storage_request(request) + return deserialize_read_response(http_resp.text()) + + async def write(self, changes: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Upsert multiple Activity state records using last-write-wins semantics.""" + body = serialize_write_request(changes) + url = self._settings.build_url("activity/state:write") + request = HttpRequest("POST", url, content=body, headers={"Content-Type": _JSON_CONTENT_TYPE}) + http_resp = await self._send_storage_request(request) + return deserialize_write_response(http_resp.text()) + + async def delete(self, keys: list[str]) -> None: + """Delete multiple Activity state records. Missing keys are ignored by the service.""" + body = serialize_delete_request(keys) + url = self._settings.build_url("activity/state:delete") + request = HttpRequest("POST", url, content=body, headers={"Content-Type": _JSON_CONTENT_TYPE}) + await self._send_storage_request(request) diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_foundry_errors.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_foundry_errors.py new file mode 100644 index 000000000000..7560bbe932f8 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_foundry_errors.py @@ -0,0 +1,73 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Exception hierarchy for Foundry storage API errors.""" +# pylint: disable=docstring-missing-param,docstring-missing-return,docstring-missing-rtype + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from azure.ai.agentserver.core._platform_headers import PLATFORM_ERROR_TAG + +if TYPE_CHECKING: + from azure.core.rest import HttpResponse + + +class FoundryStorageError(Exception): + """Base class for errors returned by the Foundry storage API.""" + + def __init__( + self, + message: str, + *, + response_body: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.message = message + self.response_body = response_body + + +class FoundryResourceNotFoundError(FoundryStorageError): + """Raised when the requested resource does not exist (HTTP 404).""" + + +class FoundryBadRequestError(FoundryStorageError): + """Raised for invalid-request or conflict errors (HTTP 400, 409).""" + + +class FoundryApiError(FoundryStorageError): + """Raised for all other non-success HTTP responses.""" + + +def raise_for_storage_error(response: "HttpResponse") -> None: + """Raise an appropriate :class:`FoundryStorageError` subclass for non-2xx responses.""" + status = response.status_code + if 200 <= status < 300: + return + + message, body = _extract_error_message(response, status) + + if status == 404: + raise FoundryResourceNotFoundError(message, response_body=body) + if status in (400, 409): + raise FoundryBadRequestError(message, response_body=body) + exc = FoundryApiError(message, response_body=body) + setattr(exc, PLATFORM_ERROR_TAG, True) + raise exc + + +def _extract_error_message(response: "HttpResponse", status: int) -> tuple[str, dict[str, Any] | None]: + """Extract a Foundry error-envelope message from *response* when possible.""" + try: + body = response.text() + if body: + data = json.loads(body) + error = data.get("error") + if isinstance(error, dict): + msg = error.get("message") + if msg: + return str(msg), data + except Exception: # pylint: disable=broad-except + pass + return f"Foundry storage request failed with HTTP {status}.", None diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_foundry_logging_policy.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_foundry_logging_policy.py new file mode 100644 index 000000000000..f5cd9cbd22af --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_foundry_logging_policy.py @@ -0,0 +1,103 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Logging policy for Foundry storage HTTP calls.""" +# pylint: disable=docstring-missing-param,docstring-missing-return,docstring-missing-rtype + +from __future__ import annotations + +import logging +import time +import urllib.parse +from typing import cast + +from azure.ai.agentserver.core._platform_headers import ( + APIM_REQUEST_ID, + CLIENT_REQUEST_ID, + REQUEST_ID, + TRACEPARENT, +) +from azure.core.pipeline import PipelineRequest, PipelineResponse +from azure.core.pipeline.policies import AsyncHTTPPolicy +from azure.core.rest import HttpResponse + +logger = logging.getLogger("azure.ai.agentserver") + + +def _mask_storage_url(url: str) -> str: + """Mask the sensitive portions of a Foundry storage URL.""" + try: + if not url: + return "(redacted)" + parsed = urllib.parse.urlparse(url) + path = parsed.path or "" + idx = path.find("/storage") + if idx < 0: + return "(redacted)" + masked = f"***{path[idx:]}" + qs = urllib.parse.parse_qs(parsed.query) + api_version = qs.get("api-version") + if api_version: + masked += f"?api-version={api_version[0]}" + return masked + except Exception: # pylint: disable=broad-exception-caught + return "(redacted)" + + +class FoundryStorageLoggingPolicy(AsyncHTTPPolicy): # type: ignore[type-arg] + """Azure Core per-retry pipeline policy that logs Foundry storage calls.""" + + async def send(self, request: PipelineRequest) -> PipelineResponse: + """Send the request and log the operation details.""" + http_request = request.http_request + method = http_request.method + url = _mask_storage_url(str(http_request.url)) + client_request_id = http_request.headers.get(CLIENT_REQUEST_ID, "") + traceparent = http_request.headers.get(TRACEPARENT, "") + + logger.debug( + "Foundry storage %s %s starting (x-ms-client-request-id=%s, traceparent=%s)", + method, + url, + client_request_id, + traceparent, + ) + + start = time.monotonic() + try: + response = await self.next.send(request) + except Exception: + elapsed_ms = (time.monotonic() - start) * 1000 + logger.error( + "Foundry storage %s %s transport failure after %.1fms " + "(x-ms-client-request-id=%s, traceparent=%s)", + method, + url, + elapsed_ms, + client_request_id, + traceparent, + exc_info=True, + ) + raise + + elapsed_ms = (time.monotonic() - start) * 1000 + http_response = cast(HttpResponse, response.http_response) + status_code = http_response.status_code + x_request_id = http_response.headers.get(REQUEST_ID, "") + apim_request_id = http_response.headers.get(APIM_REQUEST_ID, "") + + log_level = logging.INFO if 200 <= status_code < 400 else logging.WARNING + logger.log( + log_level, + "Foundry storage %s %s -> %d (%.1fms, " + "x-ms-client-request-id=%s, traceparent=%s, x-request-id=%s, apim-request-id=%s)", + method, + url, + status_code, + elapsed_ms, + client_request_id, + traceparent, + x_request_id, + apim_request_id, + ) + + return response diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_foundry_serializer.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_foundry_serializer.py new file mode 100644 index 000000000000..55f9559c78e0 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_foundry_serializer.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""JSON serialization helpers for Foundry activity state envelopes.""" +# pylint: disable=docstring-missing-param,docstring-missing-return,docstring-missing-rtype + +from __future__ import annotations + +import json +from typing import Any + + +def serialize_read_request(keys: list[str]) -> bytes: + """Serialize a batch state-read request to JSON bytes.""" + return json.dumps({"keys": keys}).encode("utf-8") + + +def serialize_write_request(changes: dict[str, Any]) -> bytes: + """Serialize a batch state-write request to JSON bytes.""" + payload = {"changes": {key: {"value": value} for key, value in changes.items()}} + return json.dumps(payload).encode("utf-8") + + +def serialize_delete_request(keys: list[str]) -> bytes: + """Serialize a batch state-delete request to JSON bytes.""" + return json.dumps({"keys": keys}).encode("utf-8") + + +def deserialize_read_response(body: str) -> dict[str, dict[str, Any]]: + """Deserialize a state-read response, preserving only returned items.""" + data = json.loads(body or "{}") + raw_items = data.get("items", {}) + if not isinstance(raw_items, dict): + return {} + + items: dict[str, dict[str, Any]] = {} + for key, item in raw_items.items(): + if isinstance(item, dict) and "value" in item: + items[str(key)] = {"value": item.get("value"), "etag": item.get("etag")} + return items + + +def deserialize_write_response(body: str) -> dict[str, dict[str, Any]]: + """Deserialize a state-write response containing per-key etags.""" + data = json.loads(body or "{}") + raw_items = data.get("items", {}) + if not isinstance(raw_items, dict): + return {} + + items: dict[str, dict[str, Any]] = {} + for key, item in raw_items.items(): + if isinstance(item, dict): + items[str(key)] = {"etag": item.get("etag")} + return items diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_foundry_settings.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_foundry_settings.py new file mode 100644 index 000000000000..43a26874d44c --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/storage/_foundry_settings.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Configuration helpers for Foundry activity state storage.""" +# pylint: disable=docstring-missing-param,docstring-missing-return,docstring-missing-rtype +# pylint: disable=docstring-keyword-should-match-keyword-only + +from __future__ import annotations + +from urllib.parse import quote as _url_quote + +from azure.ai.agentserver.core._config import AgentConfig + +_DEFAULT_API_VERSION = "v1" + + +def _encode(value: str) -> str: + return _url_quote(value, safe="") + + +class FoundryActivityStateSettings: + """Immutable runtime configuration for :class:`FoundryActivityStateClient`.""" + + def __init__(self, *, storage_base_url: str, api_version: str = _DEFAULT_API_VERSION) -> None: + self.storage_base_url = storage_base_url + self.api_version = api_version + + @classmethod + def from_env(cls, *, api_version: str = _DEFAULT_API_VERSION) -> "FoundryActivityStateSettings": + """Create settings by reading the ``FOUNDRY_PROJECT_ENDPOINT`` environment variable.""" + config = AgentConfig.from_env() + if not config.project_endpoint: + raise EnvironmentError( + "The 'FOUNDRY_PROJECT_ENDPOINT' environment variable is required. " + "In hosted environments, the Azure AI Foundry platform must set this variable." + ) + return cls.from_endpoint(config.project_endpoint, api_version=api_version) + + @classmethod + def from_endpoint(cls, endpoint: str, *, api_version: str = _DEFAULT_API_VERSION) -> "FoundryActivityStateSettings": + """Create settings from an explicit Foundry project endpoint URL.""" + if not endpoint: + raise ValueError("endpoint must be a non-empty string") + if not (endpoint.startswith("http://") or endpoint.startswith("https://")): + raise ValueError(f"endpoint must be a valid absolute URL, got: {endpoint!r}") + return cls(storage_base_url=endpoint.rstrip("/") + "/storage/", api_version=api_version) + + def build_url(self, path: str, **extra_params: str) -> str: + """Build a full storage API URL for *path* with ``api-version`` appended.""" + url = f"{self.storage_base_url}{path}?api-version={_encode(self.api_version)}" + for key, value in extra_params.items(): + url += f"&{key}={_encode(value)}" + return url diff --git a/sdk/agentserver/azure-ai-agentserver-core/pyproject.toml b/sdk/agentserver/azure-ai-agentserver-core/pyproject.toml index 5e19c7a03b89..69f1df4599ee 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/pyproject.toml +++ b/sdk/agentserver/azure-ai-agentserver-core/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ keywords = ["azure", "azure sdk", "agent", "agentserver", "core"] dependencies = [ + "azure-core>=1.30.0", "starlette>=0.45.0", "hypercorn>=0.17.0", "opentelemetry-api>=1.40.0", diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/test_foundry_activity_state_client.py b/sdk/agentserver/azure-ai-agentserver-core/tests/test_foundry_activity_state_client.py new file mode 100644 index 000000000000..776a2ef4aaa0 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/test_foundry_activity_state_client.py @@ -0,0 +1,150 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Unit tests for Foundry Activity state storage request construction.""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from azure.ai.agentserver.core._platform_headers import CHAT_ISOLATION_KEY, USER_ISOLATION_KEY +from azure.ai.agentserver.core.storage import ( + FoundryActivityStateClient, + FoundryActivityStateSettings, + FoundryApiError, + FoundryBadRequestError, +) + +_BASE_URL = "https://foundry.example.com/storage/" +_SETTINGS = FoundryActivityStateSettings(storage_base_url=_BASE_URL) + + +def _make_response(status_code: int, body: Any) -> MagicMock: + resp = MagicMock() + resp.status_code = status_code + resp.headers = {} + resp.text = MagicMock(return_value=json.dumps(body)) + return resp + + +def _make_client(response: MagicMock) -> FoundryActivityStateClient: + client = FoundryActivityStateClient.__new__(FoundryActivityStateClient) + client._settings = _SETTINGS + mock_pipeline = AsyncMock() + mock_pipeline.send_request = AsyncMock(return_value=response) + mock_pipeline.close = AsyncMock() + client._client = mock_pipeline + return client + + +def _sent_request(client: FoundryActivityStateClient): + return client._client.send_request.call_args[0][0] + + +@pytest.mark.asyncio +async def test_read_posts_keys_to_activity_state_read() -> None: + client = _make_client(_make_response(200, {"items": {"k/1": {"value": {"count": 1}, "etag": "e1"}}})) + + result = await client.read(["k/1", "missing"]) + + request = _sent_request(client) + assert request.method == "POST" + assert request.url == f"{_BASE_URL}activity/state:read?api-version=v1" + assert json.loads(request.content.decode("utf-8")) == {"keys": ["k/1", "missing"]} + assert result == {"k/1": {"value": {"count": 1}, "etag": "e1"}} + + +@pytest.mark.asyncio +async def test_read_omits_missing_keys_returned_by_service() -> None: + client = _make_client(_make_response(200, {"items": {"present": {"value": {"x": True}, "etag": "etag"}}})) + + result = await client.read(["present", "missing"]) + + assert "present" in result + assert "missing" not in result + + +@pytest.mark.asyncio +async def test_write_posts_changes_to_activity_state_write() -> None: + client = _make_client(_make_response(200, {"items": {"state:key": {"etag": "new-etag"}}})) + + result = await client.write({"state:key": {"turn": 2}}) + + request = _sent_request(client) + assert request.method == "POST" + assert request.url == f"{_BASE_URL}activity/state:write?api-version=v1" + assert json.loads(request.content.decode("utf-8")) == { + "changes": {"state:key": {"value": {"turn": 2}}} + } + assert "If-Match" not in request.headers + assert result == {"state:key": {"etag": "new-etag"}} + + +@pytest.mark.asyncio +async def test_write_last_write_wins_sends_raw_latest_value_without_etag_condition() -> None: + client = _make_client(_make_response(200, {"items": {"k": {"etag": "e2"}}})) + + await client.write({"k": {"value": "latest"}}) + + request = _sent_request(client) + assert json.loads(request.content.decode("utf-8")) == {"changes": {"k": {"value": {"value": "latest"}}}} + assert "If-Match" not in request.headers + + +@pytest.mark.asyncio +async def test_delete_posts_keys_to_activity_state_delete() -> None: + client = _make_client(_make_response(204, {})) + + await client.delete(["a/b", "c:d"]) + + request = _sent_request(client) + assert request.method == "POST" + assert request.url == f"{_BASE_URL}activity/state:delete?api-version=v1" + assert json.loads(request.content.decode("utf-8")) == {"keys": ["a/b", "c:d"]} + + +@pytest.mark.asyncio +async def test_delete_missing_keys_is_idempotent_when_service_returns_204() -> None: + client = _make_client(_make_response(204, {})) + + await client.delete(["already-gone"]) + + client._client.send_request.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_activity_state_requests_do_not_send_isolation_headers() -> None: + client = _make_client(_make_response(200, {"items": {}})) + + await client.read(["key"]) + + request = _sent_request(client) + assert USER_ISOLATION_KEY not in request.headers + assert CHAT_ISOLATION_KEY not in request.headers + + +@pytest.mark.asyncio +async def test_write_raises_bad_request_for_400() -> None: + client = _make_client(_make_response(400, {"error": {"message": "bad input"}})) + + with pytest.raises(FoundryBadRequestError, match="bad input"): + await client.write({"k": {"v": 1}}) + + +@pytest.mark.asyncio +async def test_read_raises_platform_tagged_api_error_for_500() -> None: + client = _make_client(_make_response(500, {"error": {"message": "storage down"}})) + + with pytest.raises(FoundryApiError, match="storage down") as exc_info: + await client.read(["k"]) + + assert getattr(exc_info.value, "Azure.AI.AgentServer.PlatformError") is True + + +def test_settings_from_endpoint_builds_storage_base_url() -> None: + settings = FoundryActivityStateSettings.from_endpoint("https://example.test/project/") + + assert settings.storage_base_url == "https://example.test/project/storage/" + assert settings.build_url("activity/state:read") == "https://example.test/project/storage/activity/state:read?api-version=v1" From 6ddd46db5e4cc5b8494476305832d81144482af6 Mon Sep 17 00:00:00 2001 From: Shanmukha Pasumarthy Date: Mon, 15 Jun 2026 14:06:22 +0530 Subject: [PATCH 2/2] feat(agentserver): add foundry_storage_history_agent sample Add a simple sample that persists the full conversation transcript with FoundryStorage by appending each turn to a history list in conversation state. Includes /history and /clear commands; updates samples README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../samples/README.md | 10 +++ .../foundry_storage_history_agent.py | 88 +++++++++++++++++++ .../requirements.txt | 5 ++ 3 files changed, 103 insertions(+) create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_history_agent/foundry_storage_history_agent.py create mode 100644 sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_history_agent/requirements.txt diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/README.md b/sdk/agentserver/azure-ai-agentserver-activity/samples/README.md index 7e3669e13736..49651e8b5929 100644 --- a/sdk/agentserver/azure-ai-agentserver-activity/samples/README.md +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/README.md @@ -15,6 +15,7 @@ for Foundry hosted agents. Each sample is based on a corresponding sample from t | [`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 | | [`foundry_storage_state_agent`](#foundry_storage_state_agent) | N/A | Durable conversation and user state with `FoundryStorage()` | Zero-config decorator | +| [`foundry_storage_history_agent`](#foundry_storage_history_agent) | N/A | Persist the full conversation transcript with `FoundryStorage()` | Zero-config decorator | | [`foundry_storage_proactive_agent`](#foundry_storage_proactive_agent) | N/A | Durable proactive conversation references with `FoundryStorage()` | Handler + proactive | ### Usage patterns @@ -125,6 +126,15 @@ Shows durable Activity Protocol state using platform-managed storage: - `ActivityAgentServerHost(storage=storage)` — the M365 bridge uses it for conversation/user state - `state.conversation` and `state.user` counters survive restarts and scale-out +## foundry_storage_history_agent + +The simplest durable-storage sample — persists the whole conversation transcript: + +- `storage = FoundryStorage()` — platform-managed backend, no Cosmos to configure +- `@app.activity("message")` appends each turn to a `history` list in `state.conversation` +- `/history` shows the stored transcript; `/clear` forgets it +- The transcript survives restarts and scale-out because it lives in FoundryStorage + ## foundry_storage_proactive_agent Shows durable proactive references with the M365 handler pattern: diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_history_agent/foundry_storage_history_agent.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_history_agent/foundry_storage_history_agent.py new file mode 100644 index 000000000000..4a57dc270614 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_history_agent/foundry_storage_history_agent.py @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Foundry Storage History Agent — Activity Protocol with durable history. + +The simplest durable-storage sample: it persists the full conversation +transcript with ``FoundryStorage`` so the history survives restarts and +scale-out. Each turn is appended to a list held in conversation state; +the agent echoes the running transcript back. + +Commands: + + /history show the stored transcript + /clear forget the stored transcript + +Usage:: + + python foundry_storage_history_agent.py +""" + +import logging +import sys +import traceback + +from azure.ai.agentserver.activity import ActivityAgentServerHost, FoundryStorage + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s | %(message)s", +) + +# Platform-managed storage — no Cosmos account or connection string to manage. +storage = FoundryStorage() +app = ActivityAgentServerHost(storage=storage) + + +@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 remember our conversation with FoundryStorage.\n\n" + "Send any message and I'll append it to the durable transcript. " + "Use `/history` to see it or `/clear` to forget it." + ) + + +@app.activity("message") +async def on_message(context, state): + """Persist the turn in conversation history and echo the transcript back.""" + user_text = (context.activity.text or "").strip() + if not user_text: + return + + history = state.conversation.get_value("history", lambda: []) + + if user_text == "/clear": + state.conversation.set_value("history", []) + await context.send_activity("Transcript cleared.") + return + + if user_text == "/history": + if not history: + await context.send_activity("No messages stored yet.") + else: + transcript = "\n".join(f"{i}. {line}" for i, line in enumerate(history, 1)) + await context.send_activity(f"**Stored transcript ({len(history)}):**\n\n{transcript}") + return + + history.append(f"You: {user_text}") + state.conversation.set_value("history", history) + + await context.send_activity( + f"Saved. I've persisted **{len(history)}** message(s) this conversation. " + "Send `/history` to see them all." + ) + + +@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/foundry_storage_history_agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_history_agent/requirements.txt new file mode 100644 index 000000000000..060932fccb2f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/foundry_storage_history_agent/requirements.txt @@ -0,0 +1,5 @@ +azure-ai-agentserver-activity +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-activity +azure-identity