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/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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`
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
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.0b4",
"azure-ai-agentserver-core>=2.0.0b5",
"azure-identity>=1.17.0",
"opentelemetry-api>=1.40.0",
]

Expand Down
28 changes: 28 additions & 0 deletions sdk/agentserver/azure-ai-agentserver-activity/samples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ 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_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

Expand Down Expand Up @@ -115,6 +118,31 @@ 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_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:

- `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
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
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