Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions sdk/agentserver/azure-ai-agentserver-activity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

## 1.0.0b4 (Unreleased)

### Features Added

- Added `FoundryStorage`, an M365 Agents SDK `Storage` adapter backed by
`azure.ai.agentserver.core.storage.FoundryStateStore`. Each M365 storage key
(already a scope identifier, e.g. `f"{channel_id}/conversations/{conversation_id}"`
or `f"{channel_id}/users/{user_id}"`) gets its own lazily-created, lazily-cached
`FoundryStateStore` — "store name = scope", not a single shared namespace.
Keys matching the M365 `UserState` shape automatically get
`user_isolation=True` (override via `is_user_scoped=`). Conversation, user, and
proactive-reference state written through the M365 bridge persists across
restarts and scale-out. Requires `azure-ai-agentserver-core>=2.0.0b8`.

### Other Changes

- Internal refactor only; no public API changes. Consolidated the default storage
Expand Down
21 changes: 21 additions & 0 deletions sdk/agentserver/azure-ai-agentserver-activity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ from azure.ai.agentserver.activity import ActivityAgentServerHost
app = ActivityAgentServerHost(storage=MemoryStorage())
```

**Foundry durable storage** — drop-in durable state for the M365 bridge, backed by
Foundry-managed Activity state storage instead of the in-memory default:

```python
from azure.ai.agentserver.activity import ActivityAgentServerHost, FoundryStorage

storage = FoundryStorage()
app = ActivityAgentServerHost(storage=storage)
```

**Inject a pre-built `AgentApplication`** — host an M365 `AgentApplication` you built yourself:

```python
Expand Down Expand Up @@ -108,6 +118,14 @@ app.run()
the `03-self-hosted-app` sample.
- `ActivityAgentServerHost.adapter` — the channel adapter for the underlying
`AgentApplication`.
- `FoundryStorage` — platform-managed durable storage for M365 conversation, user,
and proactive state. Implements the M365 Agents SDK `Storage` interface. Each
M365 storage key is already a scope identifier (for example
`f"{channel_id}/conversations/{conversation_id}"` or
`f"{channel_id}/users/{user_id}"`), so `FoundryStorage` backs each one with its
own lazily-created `azure.ai.agentserver.core.storage.FoundryStateStore`
("store name = scope" — no shared namespace); user-shaped keys automatically
get `user_isolation=True` (override via `is_user_scoped=`).

## Examples

Expand All @@ -118,6 +136,9 @@ See the [samples directory](https://github.com/Azure/azure-sdk-for-python/tree/m
- `03-self-hosted-app` — build the M365 `AgentApplication` yourself and host it with `from_agent_application`.
- `04-custom-handler` — own the request pipeline with `from_request_handler` (the M365 SDK is not initialized).
- `05-multi-protocol` — compose the Activity and Invocations protocols on a single server.
- `06-foundry-storage-state` — durable conversation and user state with `FoundryStorage`.
- `07-foundry-storage-proactive` — durable proactive conversation references with `FoundryStorage`.
- `08-foundry-storage-history` — persist the full conversation transcript with `FoundryStorage` (`/history` and `/clear` commands).

## Troubleshooting

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ async def handle(request):

from ._activity import ActivityAgentServerHost
from ._config import get_hosted_agent_env
from ._foundry_storage import FoundryStorage
from ._version import VERSION

__all__ = ["ActivityAgentServerHost", "get_hosted_agent_env"]
__all__ = ["ActivityAgentServerHost", "FoundryStorage", "get_hosted_agent_env"]
__version__ = VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
"""M365 Agents SDK storage adapter backed by per-key Foundry state stores."""
# 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

import asyncio
from typing import Any, Callable, Optional, Tuple, Type, TypeVar

from azure.ai.agentserver.core.storage import FoundryStateStore, FoundryStorageEndpoint, FoundryStorageNotFoundError
from azure.core.credentials_async import AsyncTokenCredential

try:
from microsoft_agents.hosting.core.storage import AsyncStorageBase, 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."""

class AsyncStorageBase(Storage): # type: ignore[no-redef]
"""Fallback base class used only when the M365 Agents SDK is not installed."""


StoreItemT = TypeVar("StoreItemT")

#: Segment the M365 Agents SDK ``UserState`` uses to build per-user storage keys
#: (``f"{channel_id}/users/{user_id}"`` -- see
#: ``microsoft_agents.hosting.core.state.user_state.UserState.get_storage_key``).
#: Keys containing this segment get ``user_isolation=True`` on their backing
#: store by default.
_USER_SCOPE_SEGMENT = "/users/"


def _default_is_user_scoped(key: str) -> bool:
"""Match the M365 ``UserState`` key shape ``"{channel_id}/users/{user_id}"``."""
return _USER_SCOPE_SEGMENT in key


class FoundryStorage(AsyncStorageBase):
"""Durable M365 Agents SDK storage adapter for Foundry-hosted Activity agents.

Backed by :class:`~azure.ai.agentserver.core.storage.FoundryStateStore`, whose
protocol binds every client to one explicit, caller-named store -- "store
name = scope". Each M365 storage key (already a scope identifier, for
example ``f"{channel_id}/conversations/{conversation_id}"`` or
``f"{channel_id}/users/{user_id}"``) gets its own lazily-created,
lazily-cached :class:`FoundryStateStore`, with the key doubling as both the
store name and the single item key stored in it. This mirrors the M365 SDK
itself, which never batches keys from different scopes in one call (every
``AgentState`` / ``Authorization`` / ``Proactive`` call operates on exactly
one key).

Subclasses :class:`~microsoft_agents.hosting.core.storage.AsyncStorageBase`,
which implements the batch :meth:`read` / :meth:`write` / :meth:`delete`
(validation + concurrent fan-out) in terms of the single-item
``_read_item`` / ``_write_item`` / ``_delete_item`` hooks below.

:keyword credential: Async token credential shared by every per-key store.
Defaults to ``DefaultAzureCredential`` (requires ``azure-identity``).
:keyword endpoint: Foundry storage endpoint or project endpoint URL override.
:keyword item_ttl_seconds: Store-level TTL applied to every per-key store
created by this adapter. Defaults to ``FoundryStateStore``'s own default
(30 days) when omitted.
:keyword is_user_scoped: Predicate deciding which keys get
``user_isolation=True`` on their backing store. Defaults to matching the
M365 ``UserState`` key shape (``"{channel_id}/users/{user_id}"``).
"""

def __init__(
self,
*,
credential: AsyncTokenCredential | None = None,
endpoint: FoundryStorageEndpoint | str | None = None,
item_ttl_seconds: int | None = None,
is_user_scoped: Callable[[str], bool] = _default_is_user_scoped,
) -> None:
self._owns_credential = credential is None
if 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
credential = DefaultAzureCredential()
self._credential = credential
self._endpoint = endpoint
self._item_ttl_seconds = item_ttl_seconds
self._is_user_scoped = is_user_scoped

self._stores: dict[str, FoundryStateStore] = {}
self._ensured_keys: set[str] = set()
self._creation_lock = asyncio.Lock()

def _new_store(self, key: str) -> FoundryStateStore:
kwargs: dict[str, Any] = {
"credential": self._credential,
"endpoint": self._endpoint,
"user_isolation": self._is_user_scoped(key),
}
if self._item_ttl_seconds is not None:
kwargs["item_ttl_seconds"] = self._item_ttl_seconds
return FoundryStateStore(key, **kwargs)

async def _get_store(self, key: str, *, ensure_exists: bool) -> FoundryStateStore:
"""Return the cached per-key store, creating the client (and, when
``ensure_exists``, the server-side store resource) on first use."""
store = self._stores.get(key)
if store is None:
async with self._creation_lock:
store = self._stores.get(key)
if store is None:
store = self._new_store(key)
self._stores[key] = store
if ensure_exists and key not in self._ensured_keys:
async with self._creation_lock:
if key not in self._ensured_keys:
await store.get_or_create()
self._ensured_keys.add(key)
return store

async def aclose(self) -> None:
"""Close every cached per-key store and an owned default credential."""
for store in list(self._stores.values()):
await store.aclose()
self._stores.clear()
self._ensured_keys.clear()
if self._owns_credential 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_item(
self,
key: str,
*,
target_cls: Type[StoreItemT] | None = None,
**kwargs: Any,
) -> Tuple[Optional[str], Optional[StoreItemT]]:
"""Fetch one item. A store that does not exist yet is just a missing key."""
_ = kwargs
store = await self._get_store(key, ensure_exists=False)
try:
item = await store.get(key)
except FoundryStorageNotFoundError:
item = None
if item is None:
return None, None
return key, target_cls.from_json_to_store_item(item.value) # type: ignore[attr-defined]

async def _write_item(self, key: str, value: StoreItemT) -> None:
"""Create-or-replace one item, creating its backing store on first write."""
store = await self._get_store(key, ensure_exists=True)
await store.set(key, value.store_item_to_json()) # type: ignore[attr-defined]

async def _delete_item(self, key: str) -> None:
"""Delete one item. Missing keys (or stores) are ignored."""
store = await self._get_store(key, ensure_exists=False)
try:
await store.delete(key)
except FoundryStorageNotFoundError:
pass
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ classifiers = [
keywords = ["azure", "azure sdk", "agent", "agentserver", "activity"]

dependencies = [
"azure-ai-agentserver-core>=2.0.0b7",
"azure-ai-agentserver-core>=2.0.0b8",
"azure-identity>=1.17.0",
"opentelemetry-api>=1.40.0",
"aiohttp>=3.10.0,<4.0.0a0",
]
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
azure-ai-agentserver-activity
microsoft-agents-hosting-core
microsoft-agents-authentication-msal
microsoft-agents-activity
azure-identity
Loading