feat(agentserver): FoundryStorage - M365 Storage adapter on Foundry d…#47978
Conversation
…urable state (Activity Protocol) Adds FoundryStorage, an implementation of the M365 Agents SDK Storage interface for azure-ai-agentserver-activity, backed by the durable FoundryStateStore state-store client from azure-ai-agentserver-core (see PR Azure#47763). Design: - Store name = scope: every M365 storage key is already a scope identifier (e.g. "{channel}/conversations/{conversation_id}", "{channel}/users/{user_id}", "proactive/conversations/{conversation_id}"), so FoundryStorage lazily creates and caches one FoundryStateStore per distinct key instead of a shared namespace. The key doubles as both the store name and the item key. - Keys shaped like the M365 UserState key ("/users/" segment) automatically get user_isolation=True on their backing store; override via is_user_scoped=. - Subclasses the M365 SDK's AsyncStorageBase and implements _read_item/_write_item/_delete_item; batching, validation, and concurrent fan-out come from the base class. - The backing store is only created (get_or_create) lazily on first write for a given key; read/delete treat a not-yet-created store as a missing key. - ActivityAgentServerHost gains a storage= override wired through the M365 bridge (falls back to MemoryStorage). Also renumbers/fixes the new FoundryStorage samples (06-08) to match the package's 01-05 sample convention and current host constructor API, and pulls in azure-ai-agentserver-core's storage/ subtree from PR Azure#47763 as-is (no other core changes) since FoundryStorage depends on FoundryStateStore's statestores-protocol shape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Thank you for your contribution @shanmukha1200! We will review the pull request and get back to you soon. |
|
@shanmukha1200 please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.
Contributor License AgreementContribution License AgreementThis Contribution License Agreement (“Agreement”) is agreed to by the party signing below (“You”),
|
2 similar comments
|
@shanmukha1200 please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.
Contributor License AgreementContribution License AgreementThis Contribution License Agreement (“Agreement”) is agreed to by the party signing below (“You”),
|
|
@shanmukha1200 please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.
Contributor License AgreementContribution License AgreementThis Contribution License Agreement (“Agreement”) is agreed to by the party signing below (“You”),
|
…e-storage spec (foundrysdk_specs#247)
Per the latest commit on coreai-microsoft/foundrysdk_specs#247
("rename route to /storage/state_stores, add store PATCH update, align
object descriptors"), the state-store REST path is /storage/state_stores/*
(snake_case with underscore), not /storage/statestores/*.
- _state.py: store path + create() now target state_stores.
- _policies.py: masked-logging allowlist updated to the new segment name.
- Updated docs/state-store-guide.md, README, and test URL assertions.
- Fixed the core CHANGELOG/README, which still described the earlier
namespace-based design (pre-dating the PR Azure#47763 pull) instead of the
current store-bound statestores-protocol API; also dropped an unused
aiohttp dependency left over from that earlier design.
No functional change beyond the route rename -- the object-type descriptor
changes in the spec commit (state_store / state_store.item) are response-only
fields the SDK does not parse or assert on.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ure#47978 review Addresses violations found auditing PR Azure#47978 against the repo's Constitution: - MyPy (release-blocking): _read_item's `# type: ignore[attr-defined]` didn't match the real error (`union-attr`), so mypy genuinely failed. Fixed by binding StoreItemT to StoreItem (imported under TYPE_CHECKING only, so the optional M365 SDK is still not a hard runtime dependency) and narrowing target_cls to non-None before use -- no type: ignore needed at all now. - Black (Principle III, "No exceptions"): _foundry_storage.py was not Black-formatted; reformatted. - Strong Type Safety (Principle II): replaced typing.Optional/Tuple/Type with PEP 604 str | None / tuple[...] / type[...] (the module already has rom __future__ import annotations). - Pylint directives: removed the file-level blanket # pylint: disable=docstring-missing-param,... (not in the allowed- suppression list) in favor of full Sphinx :param:/:keyword:/:return:/:rtype: docstrings on every public method (__init__, aclose, __aenter__, __aexit__) and the private lifecycle hooks. Moved import-error/ no-name-in-module suppressions to the specific optional-import lines, matching the existing convention in _m365_bridge.py, instead of a blanket file-level disable. Collapsed the unused fallback Storage stub into just AsyncStorageBase (dead code) and added super().__init__() to fix super-init-not-called. - Sample E2E tests (NON-NEGOTIABLE): samples 06-08 had no corresponding e2e tests. Added tests/test_storage_samples_e2e.py, replicating each sample's handler logic inline (not imported from the sample files) and driving it through the real AgentApplication + HttpAdapterBase.process_activity turn pipeline (state load -> handler -> state save -> outbound send), with MemoryStorage standing in for FoundryStorage and a fake ChannelServiceClientFactory capturing outbound sends -- full lifecycle, no network. Verified: black, mypy, and pytest all clean (99 passed). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…te/get/update/delete Per review feedback on PR Azure#47763 (constructor-then-create felt awkward) and the guide-first workflow: reshape the store-admin surface to four verbs instead of six, and stop threading the per-request delegated-user header through the constructor. - FoundryStateStore.get_or_create(name, ...) is now the sole entry point (an async classmethod): resolves the store in one call (fetch, or create on first use, refetching on a create/create race), replacing the previous constructor + separate create()/create_or_get()/get_or_create() dance. - get(key=None) and delete(key=None, ...) are overloaded on whether a key is supplied: no key acts on the bound store itself (was get_properties / delete_store); a key acts on one item (unchanged item-level behavior). update(...) replaces update_metadata(...) (no collision, simple rename). - Removed the constructor's user_id parameter. x-ms-user-id is a per-request delegation header, not a store-level setting -- it is now resolved dynamically, per call, from azure.ai.agentserver.core's existing request-scoped platform context (get_request_context().user_id), the same mechanism protocol hosts already populate from the inbound x-agent-user-id header. A single (possibly long-lived, reused) FoundryStateStore instance can now safely serve requests for different users. - azure-ai-agentserver-activity's FoundryStorage updated to call the new classmethod for writes while keeping the plain constructor for reads/ deletes (both already tolerate a not-yet-created store gracefully), so the "only create on first write" behavior is unchanged. - Rewrote the core state-store tests, the sample, and the developer guide for the new shape; added a "Limits" section to the guide mirroring the spec's field-constraints tables (no equivalent guide exists yet for the resilient-task primitive to model this section after). Verified: black, mypy, and pytest all clean for both packages (148 + 99 passed). Two mypy findings in _state.py (tags Union narrowing in update(), **query kwargs in list_keys) are pre-existing, inherited verbatim from PR Azure#47763's pulled code -- unrelated to this rename and left as-is. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…te/get/update/delete Addresses PR review feedback (constructor-then-create felt awkward) and aligns the SDK with the latest state-storage spec commit (route renamed to /storage/state_stores/*). - Route rename: /storage/statestores/* -> /storage/state_stores/* in _state.py's request paths and _policies.py's masked-logging allowlist. - FoundryStateStore.get_or_create(name, ...) is now the sole entry point (an async classmethod): resolves the store in one call (fetch, or create on first use, refetching on a create/create race), replacing the previous constructor + separate create()/create_or_get()/get_or_create() dance. - get(key=None) and delete(key=None, ...) are overloaded on whether a key is supplied: no key acts on the bound store itself (was get_properties / delete_store); a key acts on one item (unchanged item-level behavior). update(...) replaces update_metadata(...) (no collision, simple rename). - The user_id constructor parameter (delegated x-ms-user-id) is unchanged on this branch: this base predates azure.ai.agentserver.core's request-scoped platform context (added later, in 2.0.0b7), so the "resolve user_id per-request instead of at construction" fix from the sibling PR (Azure#47978) does not apply here yet without also backporting that module. Flagging for a follow-up once this PR is based on (or merges after) that work. - Rewrote the state-store tests, the sample, the developer guide, and the README snippet for the new shape; added a "Limits" section to the guide mirroring the spec's field-constraints tables. Verified: black and pytest clean for the storage surface (41 storage tests pass). Three pre-existing mypy findings in _state.py / _state_serializer.py (tags Union narrowing in update(), **query kwargs in list_keys) predate this change and are left as-is. Three unrelated pre-existing test_tracing.py failures in this branch's older tracing code (opentelemetry-sdk version mismatch) are also unrelated to this change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…s from a TypeSpec contract Replace hand-rolled dataclasses + ad hoc JSON (de)serialization in FoundryStateStore with real Python model classes generated from a formal TypeSpec contract (type_spec/main.tsp), the same @azure-tools/typespec-python emitter azure-ai-agentserver-responses uses. The contract does not live under Azure/azure-rest-api-specs yet, so models are compiled locally for now (see type_spec/README.md) instead of tsp-client sync. - Added type_spec/main.tsp (adapted from the .tsp authored against the real Vienna server contract) plus package.json/README documenting local generation, and a Makefile generate-models target. - Added azure/ai/agentserver/core/storage/_generated/: generated model classes (StateStore, StateStoreItem, StateStoreItemMetadata, DeletedStateStore, DeletedStateStoreItem, StateStoreKey, CreateStateStoreRequest, UpdateStateStoreRequest, CreateItemRequest, PutItemRequest, ListResponseStateStore(Key)) plus the model_base.py runtime it depends on. Added mypy.ini excluding _generated (matching responses' pattern) and the isodate dependency model_base.py requires. - Renamed public types to match the generated/spec names exactly: StateStoreInfo -> StateStore, StateItem -> StateStoreItem, StateItemMetadata -> StateStoreItemMetadata, DeletedStateItem -> DeletedStateStoreItem, StateKey -> StateStoreKey. KeyPage stays a hand-written convenience wrapper (no wire representation of its own). - Rewrote _state_serializer.py's serialize_*/deserialize_* helpers to build/parse the generated models instead of raw dicts. update()'s description/tags tri-state (unset vs. explicit null vs. value) now uses the generated model's mapping constructor, which -- unlike its kwargs constructor -- preserves an explicit None instead of dropping it. - Added @overload pairs to get()/delete() so callers get StateStore | None / DeletedStateStore for key=None and StateStoreItem | None / DeletedStateStoreItem for key=<str>, instead of one wide union. This surfaced a real (previously mypy-invisible, due to an editable-install artifact masking type resolution) union-attr bug in activity's _foundry_storage.py _read_item, now fixed by the narrower overload. - Updated tests (core + activity), CHANGELOG, and README for the above. 148 core + 99 activity tests pass; mypy/pylint clean aside from pre-existing, unrelated findings already called out in earlier commits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…s from a TypeSpec contract Same change as PR Azure#47978, ported onto this branch's base (which predates _request_context.py, so the constructor-based user_id parameter is unchanged here -- see FoundryStateStore.__init__/_request()). Replace hand-rolled dataclasses + ad hoc JSON (de)serialization in FoundryStateStore with real Python model classes generated from a formal TypeSpec contract (type_spec/main.tsp), the same @azure-tools/typespec-python emitter azure-ai-agentserver-responses uses. The contract does not live under Azure/azure-rest-api-specs yet, so models are compiled locally for now (see type_spec/README.md) instead of tsp-client sync. - Added type_spec/main.tsp, package.json/README, and a Makefile generate-models target. - Added azure/ai/agentserver/core/storage/_generated/: generated model classes (StateStore, StateStoreItem, StateStoreItemMetadata, DeletedStateStore, DeletedStateStoreItem, StateStoreKey, CreateStateStoreRequest, UpdateStateStoreRequest, CreateItemRequest, PutItemRequest, ListResponseStateStore(Key)) plus the model_base.py runtime it depends on. Added mypy.ini excluding _generated and the isodate dependency model_base.py requires. - Renamed public types to match the generated/spec names exactly: StateStoreInfo -> StateStore, StateItem -> StateStoreItem, StateItemMetadata -> StateStoreItemMetadata, DeletedStateItem -> DeletedStateStoreItem, StateKey -> StateStoreKey. KeyPage stays a hand-written convenience wrapper. - Rewrote _state_serializer.py's serialize_*/deserialize_* helpers to build/parse the generated models instead of raw dicts, preserving update()'s description/tags tri-state semantics via the generated model's mapping constructor. - Added @overload pairs to get()/delete() so callers get a narrow return type based on whether key is supplied, instead of one wide union. - Updated tests, CHANGELOG, and README for the above. 27/27 storage-specific tests pass (3 pre-existing, unrelated test_tracing.py failures confirmed environment/version-related); mypy clean aside from the same pre-existing finding already called out in the prior commit on this branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tate-store guide Same fix as the sibling PR Azure#47763: the guide's code samples returned typed objects (StateStore, StateStoreItem, etc.) but never imported or named the types, so every example read as if get()/set()/list_keys() returned raw dicts. Added a Typed Models section mapping each method to its return model, and added explicit imports/type annotations to the Getting Started, Store Lifecycle, Fetch-one-item, and Listing Keys examples. StateStoreItem.value stays intentionally untyped (opaque application JSON) -- called that out explicitly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…yword
The constructor, get_or_create(), update(), create_item(), and set() still
took only scattered kwargs (user_isolation, item_ttl_seconds, description,
tags) even after the underlying request bodies became typed generated
models -- there was no way to build/reuse a CreateStateStoreRequest etc. and
pass it straight through. Each of those methods now also accepts an
'options' keyword: a typed request model bundling its scattered keywords
into one object, mutually exclusive with them per call.
- Constructor / get_or_create(): options: CreateStateStoreRequest | None.
options.name is ignored -- the store's name always comes from the
required `name` parameter. Absent fields on options fall back to the
same defaults the scattered kwargs use.
- update(): options: UpdateStateStoreRequest | None. Building options via
its mapping constructor (e.g. UpdateStateStoreRequest({"tags": None}))
preserves the omit-vs-null distinction the description/tags keywords
make via the _UNSET sentinel -- a field's absence from options means
"leave unchanged", presence with None means "clear it".
- create_item() / set(): options: CreateItemRequest | PutItemRequest |
None. Only options.tags is read; key/value always come from the
method's own required parameters.
- Added _resolve_create_options()/_resolve_tags_option() helpers in
_state.py implementing the reconciliation + mutual-exclusion checks.
- Re-exported CreateStateStoreRequest, UpdateStateStoreRequest,
CreateItemRequest, and PutItemRequest from
azure.ai.agentserver.core.storage (previously internal-only, used only
by _state_serializer.py).
- Documented the new options keyword in the state-store guide (with the
update() unset-vs-null gotcha called out) and added CHANGELOG coverage.
- Added 10 new tests covering the options path, its defaulting behavior,
and the mutual-exclusion ValueError for all five methods.
158 core tests pass (148 existing + 10 new); mypy/pylint clean aside from
the same pre-existing finding already called out in prior commits;
activity package (99 tests, mypy clean) unaffected.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…yword Same change as the sibling PR Azure#47978, ported onto this branch's base (constructor-based user_id parameter unchanged here). The constructor, get_or_create(), update(), create_item(), and set() still took only scattered kwargs (user_isolation, item_ttl_seconds, description, tags) even after the underlying request bodies became typed generated models -- there was no way to build/reuse a CreateStateStoreRequest etc. and pass it straight through. Each of those methods now also accepts an 'options' keyword: a typed request model bundling its scattered keywords into one object, mutually exclusive with them per call. - Constructor / get_or_create(): options: CreateStateStoreRequest | None. options.name is ignored -- the store's name always comes from the required `name` parameter. Absent fields on options fall back to the same defaults the scattered kwargs use. - update(): options: UpdateStateStoreRequest | None. Building options via its mapping constructor (e.g. UpdateStateStoreRequest({"tags": None})) preserves the omit-vs-null distinction the description/tags keywords make via the _UNSET sentinel -- a field's absence from options means "leave unchanged", presence with None means "clear it". - create_item() / set(): options: CreateItemRequest | PutItemRequest | None. Only options.tags is read; key/value always come from the method's own required parameters. - Added _resolve_create_options()/_resolve_tags_option() helpers in _state.py implementing the reconciliation + mutual-exclusion checks. - Re-exported CreateStateStoreRequest, UpdateStateStoreRequest, CreateItemRequest, and PutItemRequest from azure.ai.agentserver.core.storage (previously internal-only, used only by _state_serializer.py). - Documented the new options keyword in the state-store guide (with the update() unset-vs-null gotcha called out) and added CHANGELOG coverage. - Added 10 new tests covering the options path, its defaulting behavior, and the mutual-exclusion ValueError for all five methods. 141/146 core tests pass (3 pre-existing, unrelated test_tracing.py failures confirmed environment/version-related, 5 skipped); mypy clean aside from the same pre-existing finding already called out in prior commits on this branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Rewrite the durable state store guide prose to be customer-first: remove internal architecture references (FoundryStorageClient pipeline, /storage/state_stores/* protocol, type_spec/main.tsp, get_request_context() internals, x-agent-user-id, "protocol's single-item PUT", on-the-wire base64url encoding) and defensive "not a raw dict / does not and cannot" framing. Content, examples, tables, and limits are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Make FoundryStateStore opinionated toward simple key-value semantics: remove the typed request-object `options=` keyword from the constructor, get_or_create(), update(), create_item(), and set(). Callers now pass plain scalar keywords only (user_isolation, item_ttl_seconds, description, tags), matching every sibling store (MAF CheckpointStorage, ADK BaseMemoryService, in-SDK InMemoryResponseProvider, azure-ai-projects BetaMemoryStoresOperations) -- none exposes generated request-body models as a caller-facing options= param. Responses stay typed. - Delete _resolve_create_options() and _resolve_tags_option() helpers. - Stop re-exporting CreateStateStoreRequest / UpdateStateStoreRequest / CreateItemRequest / PutItemRequest from storage/__init__ and the serializer __all__ (still used internally to build wire bodies). - Keep item_ttl_seconds naming (matches our own state-store wire contract; the memory-store default_ttl_seconds precedent is a different, nested resource). - Update guide (remove "Accepted request options" section, flat-kwargs examples), CHANGELOG, and tests (remove 10 options= tests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Container protocol 2.0.0 requires forwarding the opaque per-request call ID (x-agent-foundry-call-id) on every outbound Foundry 1P call so the service resolves the caller context -- including end-user isolation -- server-side. The storage client was the only 1P client not doing so; it forwarded the end-user identity as x-ms-user-id on item operations instead. Make the call ID the sole hosted-agent user derivation: - Add PlatformCallIdPolicy (storage/_policies.py) that stamps the call ID from get_request_context().platform_headers() on every storage request, and wire it into the FoundryStorageClient pipeline. - Remove the x-ms-user-id (DELEGATED_USER_ID_HEADER) forwarding and the include_user_id plumbing from FoundryStateStore._request() and all call sites. - Update the state-store guide's User Isolation section and the CHANGELOG. - Replace the delegated-user tests with focused PlatformCallIdPolicy tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…h sign-in keys
- Cap the per-key FoundryStateStore client cache at 1024 entries using an
OrderedDict LRU; evicted stores are closed (server-side state untouched,
recreated on next access). Prevents unbounded client growth under many keys.
- Broaden _default_is_user_scoped to also flag M365 Authorization sign-in
state keys (auth:_SignInState:{channel}:{user_id}) as user-isolated, not
just UserState keys ({channel}/users/{user_id}).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…urable state (Activity Protocol)
Adds FoundryStorage, an implementation of the M365 Agents SDK Storage interface for azure-ai-agentserver-activity, backed by the durable FoundryStateStore state-store client from azure-ai-agentserver-core (see PR #47763).
Design:
Also renumbers/fixes the new FoundryStorage samples (06-08) to match the package's 01-05 sample convention and current host constructor API, and pulls in azure-ai-agentserver-core's storage/ subtree from PR #47763 as-is (no other core changes) since FoundryStorage depends on FoundryStateStore's statestores-protocol shape.
Description
Please add an informative description that covers that changes made by the pull request and link all relevant issues.
If an SDK is being regenerated based on a new API spec, a link to the pull request containing these API spec changes should be included above.
All SDK Contribution checklist:
General Guidelines and Best Practices
Testing Guidelines