Python: Update agentserver to x.1.0b1 (#7621)

* Update agentserver to 2.1.0

* Update agentserver responses and invocations to x.1.0b1

* Pass platform context to state store provider

* Pass user id

* Correct requirements.txt

* Fix unit tests

* Fix unit tests
This commit is contained in:
Tao Chen
2026-08-14 00:02:17 +00:00
committed by GitHub
parent 9645d33cde
commit ee27065359
20 changed files with 4148 additions and 4061 deletions
@@ -762,7 +762,7 @@ class ContentUnderstandingContextProvider(ContextProvider):
result,
include_markdown="markdown" in self.output_sections,
include_fields="fields" in self.output_sections,
metadata={"source": filename},
custom_metadata={"source": filename},
)
# ------------------------------------------------------------------
@@ -1663,13 +1663,13 @@ class TestAnalyzerAutoDetectionE2E:
class TestWarningsExtraction:
"""Verify that CU RAI warnings are surfaced via ``to_llm_input`` rendering.
The SDK serializes ``result.warnings`` under the reserved ``rai_warnings``
The SDK serializes ``result.warnings`` under the reserved ``warnings``
YAML front-matter key. Telemetry filtering of stray ``LLMStats:`` lines is
handled by the SDK helper (azure-ai-contentunderstanding >= 1.2.0b2).
"""
def test_warnings_included_when_present(self) -> None:
"""Non-empty warnings should appear under ``rai_warnings`` front-matter key."""
"""Non-empty warnings should appear under ``warnings`` front-matter key."""
provider = _make_provider()
fixture = {
"contents": [
@@ -1694,16 +1694,16 @@ class TestWarningsExtraction:
result_obj = AnalysisResult(fixture)
rendered = provider._render_for_llm(result_obj, "doc.pdf")
assert "rai_warnings:" in rendered
assert "warnings:" in rendered
assert "ContentFiltered" in rendered
assert "Content was filtered due to Responsible AI policy." in rendered
assert "Violence content detected and filtered." in rendered
def test_warnings_omitted_when_empty(self, pdf_analysis_result: AnalysisResult) -> None:
"""The PDF fixture has no warnings, so ``rai_warnings:`` should not appear."""
"""The PDF fixture has no warnings, so ``warnings:`` should not appear."""
provider = _make_provider()
rendered = provider._render_for_llm(pdf_analysis_result, "report.pdf")
assert "rai_warnings:" not in rendered
assert "warnings:" not in rendered
class TestCategoryExtraction:
+32 -7
View File
@@ -4,21 +4,46 @@ This package provides the integration of Agent Framework agents and workflows wi
## State store
### Local persistence
Outside the Foundry hosting environment, state is persisted as JSON files under
`~/.agentserver/state_stores` by default. Set `AGENTSERVER_STATE_ROOT` to use a
different root directory; the files will be written to its `state_stores`
subdirectory instead.
Each logical store is saved as one JSON file whose name is a URL-safe Base64
encoding of the store name. For example:
- Agent sessions: `YWdlbnRfc2Vzc2lvbnM.json`
- Function approvals: `ZnVuY3Rpb25fYXBwcm92YWxz.json`
- Workflow checkpoints: one file per context, encoded from `checkpoints/<context_id>`
> Read more about the Foundry durable state store in the [developer guide](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-core/docs/state-store-guide.md).
### User isolation
When hosted on Foundry, the default state stores automatically isolate data by the
platform user ID supplied with each request. Sessions, workflow checkpoints, and
function approvals written for one user cannot be read or modified by another user.
No additional partitioning configuration is required when using the default stores.
### Agent Sessions
`ResponsesHostServer` persists the Agent Framework `AgentSession` durably. By default it
uses the `FoundryAgentSessionStore` when hosted and an in-memory `SessionStore` locally.
When hosted, the stored sessions will be isolated by the platform user ID and scoped
under `agent_sessions`.
uses the `FoundryAgentSessionStore`, backed by Foundry storage when hosted and file-based
storage locally. Stored sessions are scoped under `agent_sessions`.
See the [custom storage provider sample](../../samples/04-hosting/foundry-hosted-agents/responses/custom_storage/)
for an example that uses an in-memory session store locally and Azure Cosmos DB when hosted.
### Workflow checkpoints
`ResponsesHostServer` persists workflow checkpoints durably. By default, it uses the
`FoundryCheckpointStore` when hosted and an in-memory `InMemoryCheckpointStorage` locally.
When hosted, the stored checkpoints will be isolated by the platform user ID and scoped
under `checkpoints`.
`FoundryCheckpointStore`, backed by Foundry storage when hosted and file-based storage
locally. Stored checkpoints are scoped under `checkpoints`.
### Function approvals
`ResponsesHostServer` persists function approvals durably. By default, it uses the
`FoundryFunctionApprovalStore` when hosted and an in-memory `InMemoryFunctionApprovalStore` locally. When hosted, the stored approvals will be isolated by the platform user ID and scoped under `function_approvals`.
`FoundryFunctionApprovalStore`, backed by Foundry storage when hosted and file-based
storage locally. Stored approvals are scoped under `function_approvals`.
@@ -9,7 +9,6 @@ from starlette.responses import Response, StreamingResponse
from typing_extensions import Any, AsyncGenerator
from ._feature_usage import FeatureIndex
from ._request_context import validate_foundry_request_context
class InvocationsHostServer(InvocationAgentServerHost):
@@ -55,7 +54,6 @@ class InvocationsHostServer(InvocationAgentServerHost):
RuntimeError: If the context doesn't contain the expected IDs.
"""
context = get_request_context()
validate_foundry_request_context(context, is_hosted=self.config.is_hosted)
if self.config.is_hosted:
if not context.session_id or not context.user_id:
@@ -1,53 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import os
from typing import Literal
from azure.ai.agentserver.core import FoundryAgentRequestContext
_PROTOCOL_V2_REQUIRED_MESSAGE = (
"The hosted environment is running on protocol 1.0.0, but the agent requires protocol 2.0.0. "
"Please upgrade your agent protocol to 2.0.0 in `agent.manifest.yaml` or `agent.yaml`, or "
"downgrade the `agent-framework-foundry-hosting` package to `1.0.0a260625` or before to use 1.0.0."
)
def validate_path_segment(
segment: str,
*,
kind: Literal["context id", "user id"],
) -> None:
"""Validate that ``segment`` is a single safe path component (CWE-22).
Request context values are untrusted when used as path segments. Reject
separators, drive letters, parent references, and similar values rather
than attempting to sanitize them and risk collisions.
"""
if not isinstance(segment, str) or not segment:
raise RuntimeError(f"Invalid {kind}: must be a non-empty string.")
if (
"/" in segment
or "\\" in segment
or "\x00" in segment
or segment.strip(".") == ""
or os.path.isabs(segment)
or os.path.splitdrive(segment)[0]
):
raise RuntimeError(f"Invalid {kind}: {segment!r}")
def validate_foundry_request_context(
context: FoundryAgentRequestContext,
*,
is_hosted: bool,
) -> None:
"""Validate that a hosted request contains protocol-v2 user identity."""
if is_hosted and context.call_id is None:
raise RuntimeError(_PROTOCOL_V2_REQUIRED_MESSAGE)
if is_hosted and not context.user_id:
raise RuntimeError(
"The hosted environment is missing the platform user ID in the request context. "
"Please ensure that the request is coming from a valid Foundry platform service."
)
@@ -6,6 +6,7 @@ import asyncio
import base64
import json
import logging
import os
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
from contextlib import AbstractAsyncContextManager, AsyncExitStack
from dataclasses import asdict, dataclass, is_dataclass
@@ -62,10 +63,6 @@ from mcp import McpError
from typing_extensions import Any
from ._feature_usage import FeatureIndex
from ._request_context import (
validate_foundry_request_context,
validate_path_segment,
)
from ._state_store import (
AgentSessionStoreProvider,
CheckpointStoreProvider,
@@ -80,6 +77,20 @@ logger = logging.getLogger(__name__)
_HOSTED_RESPONSES_HISTORY_SOURCE_ID = "_foundry_responses_history"
def _validate_checkpoint_context_id(context_id: str) -> None:
"""Validate that a checkpoint context ID is a single safe path component in case file-based storage is used."""
if (
not context_id
or "/" in context_id
or "\\" in context_id
or "\x00" in context_id
or context_id.strip(".") == ""
or os.path.isabs(context_id)
or os.path.splitdrive(context_id)[0]
):
raise RuntimeError(f"Invalid context id: {context_id!r}")
def _is_hosted_responses_history_sentinel(provider: ContextProvider) -> bool:
"""Return whether ``provider`` is the host's transient history buffer."""
return (
@@ -311,9 +322,6 @@ class ResponsesHostServer(ResponsesAgentServerHost):
cancellation_signal: asyncio.Event,
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
"""Handle the creation of a response."""
request_context = get_request_context()
validate_foundry_request_context(request_context, is_hosted=self.config.is_hosted)
if self._is_workflow_agent:
# Workflow agents are handled differently because they require checkpoint restoration
return self._handle_inner_workflow(request, context)
@@ -370,32 +378,24 @@ class ResponsesHostServer(ResponsesAgentServerHost):
return
try:
approval_storage = self._function_approval_storage_provider.get_store(config=self.config)
session_storage = self._session_storage_provider.get_store(config=self.config)
# Agent sessions are either tied to the conversation_id (for multi-turn conversation mode)
# or the previous_response_id (for response chaining). If neither is present, a new session
# is created for this request and stored under the current response_id. The current response_id
# will become the previous_response_id for the next request in a response chain, allowing the
# session to be retrieved.
if (previous_response_id := request.get("previous_response_id")) is not None:
session = await session_storage.get(previous_response_id)
if session is None:
request_context = get_request_context()
approval_storage = self._function_approval_storage_provider.get_store(
config=self.config, platform_context=request_context
)
session_storage = self._session_storage_provider.get_store(
config=self.config, platform_context=request_context
)
previous_response_id = request.get("previous_response_id")
session_load_id = context.conversation_id or previous_response_id
session = await session_storage.get(session_load_id) if session_load_id is not None else None
if session is None:
if previous_response_id is not None and context.conversation_id is None:
raise RuntimeError(
f"Cannot find an existing agent session for previous_response_id={previous_response_id}. "
"Ensure that the previous response was created successfully and that the ID is correct."
f"Cannot find an existing agent session for previous_response_id={previous_response_id}."
)
elif (conversation_id := context.conversation_id) is not None:
session = await session_storage.get(conversation_id)
if session is None:
# Note that we cannot determine if the session was deleted or never existed,
# so we log a warning and create a new session.
logger.info(
"Cannot find an existing agent session for id=%s. Creating a new session.",
conversation_id,
)
session = self._agent.create_session()
else:
session = self._agent.create_session()
session_save_id = context.conversation_id or context.response_id
except Exception as ex:
logger.error("Failed to prepare state storage: %s", ex, exc_info=(type(ex), ex, ex.__traceback__))
for event in self._emit_failure(response_event_stream, None, ex):
@@ -456,7 +456,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
if self._uses_hosted_responses_history:
session.state.pop(_HOSTED_RESPONSES_HISTORY_SOURCE_ID, None)
try:
await session_storage.set(context.conversation_id or context.response_id, session)
await session_storage.set(session_save_id, session)
except Exception as save_error:
save_failure = save_error
if request_interrupted:
@@ -498,7 +498,10 @@ class ResponsesHostServer(ResponsesAgentServerHost):
tracker: _OutputItemTracker | None = None
try:
approval_storage = self._function_approval_storage_provider.get_store(config=self.config)
request_context = get_request_context()
approval_storage = self._function_approval_storage_provider.get_store(
config=self.config, platform_context=request_context
)
input_items = await context.get_input_items()
input_messages = await _items_to_messages(input_items, approval_storage=approval_storage)
@@ -506,10 +509,6 @@ class ResponsesHostServer(ResponsesAgentServerHost):
if are_options_set:
logger.warning("Workflow agent doesn't support runtime options. They will be ignored.")
if request.get("previous_response_id") is not None and context.conversation_id is not None:
raise RuntimeError("Previous response ID cannot be used in conjunction with conversation ID.")
context_id = request.get("previous_response_id") or context.conversation_id
if not isinstance(self._agent, WorkflowAgent):
raise RuntimeError("Agent is not a workflow agent.")
@@ -518,42 +517,42 @@ class ResponsesHostServer(ResponsesAgentServerHost):
# any future async resources owned by the workflow are entered here.
await self._ensure_agent_ready()
checkpoint_save_id = context.conversation_id or context.response_id
_validate_checkpoint_context_id(checkpoint_save_id)
checkpoint_storage = self._checkpoint_storage_provider.get_store(
config=self.config,
context_id=checkpoint_save_id,
platform_context=request_context,
)
# Determine the latest checkpoint (if any) so we can resume the
# workflow's prior state for this turn. The directory is keyed by
# the inbound context id (conversation_id when set, otherwise
# previous_response_id). Multi-turn declarative workflows need the
# workflow's internal state (e.g. Conversation.messages,
# the platform derived context_id. Multi-turn declarative workflows
# need the workflow's internal state (e.g. Conversation.messages,
# intermediate Local.* variables) to survive across user turns;
# the only place that state lives is the workflow checkpoint, so
# on every turn we restore the latest checkpoint and feed the new
# input back into the start executor as a continuation rather than
# a fresh run.
latest_checkpoint_id: str | None = None
restore_storage: CheckpointStorage | None = None
if context_id is not None:
validate_path_segment(context_id, kind="context id")
restore_storage = self._checkpoint_storage_provider.get_store(
config=self.config,
context_id=context_id,
)
latest_checkpoint = await restore_storage.get_latest(workflow_name=self._agent.workflow.name)
if latest_checkpoint is not None:
latest_checkpoint_id = latest_checkpoint.checkpoint_id
# Storage that will receive checkpoints written during this turn.
# When the caller chains with previous_response_id, the next turn
# will reference the current response_id as its previous_response_id,
# so new checkpoints must land under the current response_id (or the
# conversation_id when set). When conversation_id is set, this
# matches restore_storage; when only previous_response_id was
# supplied, restore_storage points at the *prior* response's
# directory and write_storage points at the *current* response's.
write_context_id = context.conversation_id or context.response_id
validate_path_segment(write_context_id, kind="context id")
write_storage = self._checkpoint_storage_provider.get_store(
config=self.config,
context_id=write_context_id,
)
if request.get("previous_response_id") is not None and context.conversation_id is not None:
raise RuntimeError("Previous response ID cannot be used in conjunction with conversation ID.")
previous_response_id = request.get("previous_response_id")
checkpoint_load_id = context.conversation_id or previous_response_id
latest_checkpoint = None
restore_checkpoint_storage = checkpoint_storage
if checkpoint_load_id is not None:
_validate_checkpoint_context_id(checkpoint_load_id)
if checkpoint_load_id != checkpoint_save_id:
restore_checkpoint_storage = self._checkpoint_storage_provider.get_store(
config=self.config,
context_id=checkpoint_load_id,
platform_context=request_context,
)
latest_checkpoint = await restore_checkpoint_storage.get_latest(workflow_name=self._agent.workflow.name)
if latest_checkpoint is None and previous_response_id is not None:
raise RuntimeError(
f"Cannot find an existing workflow checkpoint for previous_response_id={previous_response_id}."
)
# Multi-turn pattern: when we have a prior checkpoint, restore it
# first (drive the workflow back to idle with prior state intact),
@@ -571,11 +570,11 @@ class ResponsesHostServer(ResponsesAgentServerHost):
# ``run(input_messages, ...)`` call may contain ``function_call_output``
# items (carried as FunctionResult/FunctionApprovalResponse content)
# that fulfill them via :meth:`WorkflowAgent._process_pending_requests`.
if latest_checkpoint_id is not None:
if latest_checkpoint is not None:
async for _ in self._agent.run(
stream=True,
checkpoint_id=latest_checkpoint_id,
checkpoint_storage=restore_storage,
checkpoint_id=latest_checkpoint.checkpoint_id,
checkpoint_storage=restore_checkpoint_storage,
):
pass
@@ -585,7 +584,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
async for update in self._agent.run(
input_messages,
stream=True,
checkpoint_storage=write_storage,
checkpoint_storage=checkpoint_storage,
):
for content in update.contents:
for event in tracker.handle(content):
@@ -600,27 +599,12 @@ class ResponsesHostServer(ResponsesAgentServerHost):
# Close any remaining active builder
for event in tracker.close():
yield event
await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name)
yield response_event_stream.emit_completed()
except Exception as ex:
logger.exception("Failed to produce response for workflow agent")
for event in self._emit_failure(response_event_stream, tracker, ex):
yield event
@staticmethod
async def _delete_not_latest_checkpoints(checkpoint_storage: CheckpointStorage, workflow_name: str) -> None:
"""Delete all checkpoints except the latest one.
We only need the last checkpoint for each invocation.
"""
latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=workflow_name)
if latest_checkpoint is not None:
all_checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow_name)
for checkpoint in all_checkpoints:
if checkpoint.checkpoint_id != latest_checkpoint.checkpoint_id:
await checkpoint_storage.delete(checkpoint.checkpoint_id)
@staticmethod
def _emit_failure(
response_event_stream: ResponseEventStream,
@@ -10,12 +10,11 @@ from agent_framework import (
CheckpointID,
CheckpointStorage,
Content,
InMemoryCheckpointStorage,
SessionStore,
WorkflowCheckpoint,
WorkflowCheckpointException,
)
from azure.ai.agentserver.core import AgentConfig
from azure.ai.agentserver.core import AgentConfig, FoundryAgentRequestContext
from azure.ai.agentserver.core.storage import FoundryStateStore, FoundryStorageConflictError
StoreT = TypeVar("StoreT")
@@ -25,11 +24,12 @@ class StoreProvider(ABC, Generic[StoreT]):
"""Provide store for a hosting environment."""
@abstractmethod
def get_store(self, *, config: AgentConfig) -> StoreT:
def get_store(self, *, config: AgentConfig, platform_context: FoundryAgentRequestContext) -> StoreT:
"""Get store for a hosting environment.
Args:
config: The resolved agent server configuration.
platform_context: The request-scoped platform context for the current request.
Returns:
The store instance for the given hosting environment.
@@ -45,12 +45,19 @@ class ContextScopedStoreProvider(ABC, Generic[StoreT]):
"""
@abstractmethod
def get_store(self, *, config: AgentConfig, context_id: str) -> StoreT:
def get_store(
self,
*,
config: AgentConfig,
context_id: str,
platform_context: FoundryAgentRequestContext,
) -> StoreT:
"""Get a context-scoped store for a hosting environment.
Args:
config: The resolved agent server configuration.
context_id: A string that uniquely identifies the context for which the store is scoped.
platform_context: The request-scoped platform context for the current request.
Returns:
The store instance for the given hosting environment and context ID.
@@ -65,21 +72,25 @@ class FoundryCheckpointStore:
DEFAULT_ROOT_SCOPE = "checkpoints"
def __init__(self, context_id: str) -> None:
def __init__(self, context_id: str, platform_context: FoundryAgentRequestContext) -> None:
"""Initialize a Foundry-scoped checkpoint store for the given context ID.
Args:
context_id: A string that uniquely identifies the context for which the checkpoint store is scoped.
This can be used to isolate checkpoints for different workflow runs.
platform_context: The request-scoped platform context for the current request.
"""
if not context_id:
raise ValueError("context_id must be provided to initialize a FoundryCheckpointStore.")
self.context_id = context_id
self.platform_context = platform_context
async def _get_store(self) -> FoundryStateStore:
return await FoundryStateStore.get_or_create(
f"{self.DEFAULT_ROOT_SCOPE}/{self.context_id}", user_isolation=True
f"{self.DEFAULT_ROOT_SCOPE}/{self.context_id}",
user_isolation=True,
user_id=self.platform_context.user_id,
)
async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
@@ -99,7 +110,7 @@ class FoundryCheckpointStore:
store = await self._get_store()
async with store:
await store.set_item(checkpoint.checkpoint_id, encoded_checkpoint)
await store.set_item(checkpoint.checkpoint_id, encoded_checkpoint, call_id=self.platform_context.call_id)
return checkpoint.checkpoint_id
async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
@@ -118,7 +129,7 @@ class FoundryCheckpointStore:
store = await self._get_store()
async with store:
item = await store.get_item(checkpoint_id)
item = await store.get_item(checkpoint_id, call_id=self.platform_context.call_id)
if item is None:
raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}")
return WorkflowCheckpoint.from_dict(decode_checkpoint_value(item.value))
@@ -132,9 +143,9 @@ class FoundryCheckpointStore:
after: str | None = None
async with store:
while True:
page = await store.list_keys(after=after)
page = await store.list_keys(after=after, call_id=self.platform_context.call_id)
for item_key in page.keys:
item = await store.get_item(item_key.key)
item = await store.get_item(item_key.key, call_id=self.platform_context.call_id)
if item is None:
continue
checkpoint = WorkflowCheckpoint.from_dict(decode_checkpoint_value(item.value))
@@ -148,7 +159,7 @@ class FoundryCheckpointStore:
async def delete(self, checkpoint_id: CheckpointID) -> bool:
store = await self._get_store()
async with store:
deleted_item = await store.delete_item(checkpoint_id)
deleted_item = await store.delete_item(checkpoint_id, call_id=self.platform_context.call_id)
return deleted_item.id is not None
async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None:
@@ -169,29 +180,21 @@ class CheckpointStoreProvider(ContextScopedStoreProvider[CheckpointStorage]):
collection boundary used to list checkpoints, restore the latest checkpoint,
and clean up older checkpoints without affecting another workflow context.
This will default to using the `FoundryCheckpointStore` when hosted in Foundry,
and an in-memory store otherwise.
This defaults to using the `FoundryCheckpointStore` in all environments.
"""
def __init__(self) -> None:
self._foundry_storages: dict[str, CheckpointStorage] = {}
self._in_memory_storages: dict[str, CheckpointStorage] = {}
def get_store(
self,
*,
config: AgentConfig,
context_id: str,
platform_context: FoundryAgentRequestContext,
) -> CheckpointStorage:
"""Get checkpoint store for the requested hosting environment."""
stores = self._foundry_storages if config.is_hosted else self._in_memory_storages
if not context_id:
raise ValueError("context_id must be provided to get a checkpoint store.")
if context_id not in stores:
stores[context_id] = FoundryCheckpointStore(context_id) if config.is_hosted else InMemoryCheckpointStorage()
return stores[context_id]
return FoundryCheckpointStore(context_id, platform_context)
# endregion Checkpoint persistence
@@ -222,63 +225,42 @@ class FoundryFunctionApprovalStore:
DEFAULT_ROOT_SCOPE = "function_approvals"
def __init__(self, platform_context: FoundryAgentRequestContext) -> None:
self.platform_context = platform_context
async def _get_store(self) -> FoundryStateStore:
return await FoundryStateStore.get_or_create(self.DEFAULT_ROOT_SCOPE, user_isolation=True)
return await FoundryStateStore.get_or_create(
self.DEFAULT_ROOT_SCOPE,
user_isolation=True,
user_id=self.platform_context.user_id,
)
async def save_approval_request(self, approval_request_id: str, request: Content) -> None:
store = await self._get_store()
async with store:
try:
await store.create_item(approval_request_id, request.to_dict())
await store.create_item(approval_request_id, request.to_dict(), call_id=self.platform_context.call_id)
except FoundryStorageConflictError as ex:
raise ValueError(f"Approval request with ID '{approval_request_id}' already exists.") from ex
async def load_approval_request(self, approval_request_id: str) -> Content:
store = await self._get_store()
async with store:
item = await store.get_item(approval_request_id)
item = await store.get_item(approval_request_id, call_id=self.platform_context.call_id)
if item is None:
raise KeyError(f"Approval request with ID '{approval_request_id}' does not exist.")
return Content.from_dict(item.value)
class InMemoryFunctionApprovalStore:
"""An in-memory store for function approval requests."""
def __init__(self) -> None:
self._store: dict[str, Content] = {}
async def save_approval_request(self, approval_request_id: str, request: Content) -> None:
if approval_request_id in self._store:
raise ValueError(f"Approval request with ID '{approval_request_id}' already exists.")
self._store[approval_request_id] = request
async def load_approval_request(self, approval_request_id: str) -> Content:
if approval_request_id not in self._store:
raise KeyError(f"Approval request with ID '{approval_request_id}' does not exist.")
return self._store[approval_request_id]
class FunctionApprovalStoreProvider(StoreProvider[FunctionApprovalStore]):
"""Provide function approval store for the active hosting environment.
This will default to using the `FoundryFunctionApprovalStore` when hosted in Foundry,
and an in-memory store otherwise.
This defaults to using the `FoundryFunctionApprovalStore` in all environments.
"""
def __init__(self) -> None:
self._foundry_storage: FunctionApprovalStore | None = None
self._in_memory_storage: FunctionApprovalStore | None = None
def get_store(self, *, config: AgentConfig) -> FunctionApprovalStore:
def get_store(self, *, config: AgentConfig, platform_context: FoundryAgentRequestContext) -> FunctionApprovalStore:
"""Get function approval store for the requested hosting environment."""
if config.is_hosted:
if self._foundry_storage is None:
self._foundry_storage = FoundryFunctionApprovalStore()
return self._foundry_storage
if self._in_memory_storage is None:
self._in_memory_storage = InMemoryFunctionApprovalStore()
return self._in_memory_storage
return FoundryFunctionApprovalStore(platform_context)
# endregion Function approval persistence
@@ -291,13 +273,20 @@ class FoundryAgentSessionStore(SessionStore):
DEFAULT_ROOT_SCOPE = "agent_sessions"
def __init__(self, platform_context: FoundryAgentRequestContext) -> None:
self.platform_context = platform_context
async def _get_store(self) -> FoundryStateStore:
return await FoundryStateStore.get_or_create(f"{self.DEFAULT_ROOT_SCOPE}", user_isolation=True)
return await FoundryStateStore.get_or_create(
f"{self.DEFAULT_ROOT_SCOPE}",
user_isolation=True,
user_id=self.platform_context.user_id,
)
async def get(self, session_id: str) -> AgentSession | None:
store = await self._get_store()
async with store:
item = await store.get_item(session_id)
item = await store.get_item(session_id, call_id=self.platform_context.call_id)
if item is None:
return None
return AgentSession.from_dict(item.value)
@@ -305,34 +294,23 @@ class FoundryAgentSessionStore(SessionStore):
async def set(self, session_id: str, session: AgentSession) -> None:
store = await self._get_store()
async with store:
await store.set_item(session_id, session.to_dict())
await store.set_item(session_id, session.to_dict(), call_id=self.platform_context.call_id)
async def delete(self, session_id: str) -> None:
store = await self._get_store()
async with store:
await store.delete_item(session_id)
await store.delete_item(session_id, call_id=self.platform_context.call_id)
class AgentSessionStoreProvider(StoreProvider[SessionStore]):
"""Provide agent session store for the active hosting environment.
This will default to using the `FoundryAgentSessionStore` when hosted in Foundry,
and an in-memory store otherwise.
This defaults to using the `FoundryAgentSessionStore` in all environments.
"""
def __init__(self) -> None:
self._foundry_storage: SessionStore | None = None
self._in_memory_storage: SessionStore | None = None
def get_store(self, *, config: AgentConfig) -> SessionStore:
def get_store(self, *, config: AgentConfig, platform_context: FoundryAgentRequestContext) -> SessionStore:
"""Get agent session store for the requested hosting environment."""
if config.is_hosted:
if self._foundry_storage is None:
self._foundry_storage = FoundryAgentSessionStore()
return self._foundry_storage
if self._in_memory_storage is None:
self._in_memory_storage = SessionStore()
return self._in_memory_storage
return FoundryAgentSessionStore(platform_context)
# endregion Agent session persistence
@@ -24,9 +24,9 @@ classifiers = [
]
dependencies = [
"agent-framework-core>=1.13.0,<2",
"azure-ai-agentserver-core>=2.0.0b11,<3",
"azure-ai-agentserver-responses>=2.0.0b1,<3",
"azure-ai-agentserver-invocations>=1.0.0b8,<2",
"azure-ai-agentserver-core>=2.1.0b1,<3",
"azure-ai-agentserver-responses>=2.1.0b1,<3",
"azure-ai-agentserver-invocations>=1.1.0b1,<2",
"httpx>=0.28,<1",
"mcp>=1.24.0,<2",
]
@@ -169,21 +169,12 @@ class TestPartitionKey:
with _request_context(), pytest.raises(RuntimeError, match="missing session_id"):
server._partition_key() # pyright: ignore[reportPrivateUsage]
def test_hosted_without_call_id_raises_protocol_error(self) -> None:
server = InvocationsHostServer(_make_agent(response_text="hi"))
server.config.is_hosted = True
with (
_request_context(session_id="sess-1", user_id="user-1"),
pytest.raises(RuntimeError, match="protocol 2.0.0"),
):
server._partition_key() # pyright: ignore[reportPrivateUsage]
def test_hosted_missing_user_id_raises(self) -> None:
server = InvocationsHostServer(_make_agent(response_text="hi"))
server.config.is_hosted = True
with (
_request_context(call_id="call-1", session_id="sess-1"),
pytest.raises(RuntimeError, match="missing the platform user ID"),
pytest.raises(RuntimeError, match="missing session_id or user_id"),
):
server._partition_key() # pyright: ignore[reportPrivateUsage]
@@ -13,8 +13,7 @@ from __future__ import annotations
import asyncio
import json
import uuid
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Generator, Mapping, Sequence
from contextlib import contextmanager
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Literal, cast, overload
from unittest.mock import AsyncMock, MagicMock, patch
@@ -47,11 +46,7 @@ from agent_framework import (
executor,
tool,
)
from azure.ai.agentserver.core import (
FoundryAgentRequestContext,
reset_request_context,
set_request_context,
)
from azure.ai.agentserver.core import get_request_context
from azure.ai.agentserver.responses import InMemoryResponseProvider, ResponseContext
from azure.ai.agentserver.responses.models import CreateResponse, Item, OutputItem
from mcp import McpError
@@ -70,10 +65,15 @@ from agent_framework_foundry_hosting._state_store import (
AgentSessionStoreProvider,
CheckpointStoreProvider,
FunctionApprovalStoreProvider,
InMemoryFunctionApprovalStore,
)
def _function_approval_store(request: Content) -> MagicMock:
storage = MagicMock()
storage.load_approval_request = AsyncMock(return_value=request)
return storage
def _make_function_approval_request_content(
*,
request_id: str = "apr_test",
@@ -89,21 +89,6 @@ def _make_function_approval_request_content(
return Content.from_function_approval_request(request_id, function_call)
@contextmanager
def _request_context(
*,
call_id: str | None = None,
user_id: str | None = None,
session_id: str | None = None,
) -> Generator[None]:
"""Install a Foundry request context for the duration of the block."""
token = set_request_context(FoundryAgentRequestContext(call_id=call_id, user_id=user_id, session_id=session_id))
try:
yield
finally:
reset_request_context(token)
# region Helpers
@@ -419,44 +404,6 @@ class TestResponsesHostServerInit:
with pytest.raises(RuntimeError, match="history provider"):
ResponsesHostServer(agent)
async def test_hosted_request_requires_user_partition_key(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
server = _make_server(agent)
request = CreateResponse(model="m", input="hi")
context = ResponseContext(
response_id="caresp_aaaaaaaaaaaaaaaa00" + "1" * 32,
mode_flags=MagicMock(),
)
with (
patch.object(server.config, "is_hosted", True),
_request_context(call_id="call-1"),
pytest.raises(RuntimeError, match="platform user ID"),
):
await server._handle_response( # pyright: ignore[reportPrivateUsage]
request,
context,
asyncio.Event(),
)
async def test_hosted_request_requires_protocol_v2(self) -> None:
server = _make_server(_make_agent())
request = CreateResponse(model="m", input="hi")
context = ResponseContext(response_id="response-1", mode_flags=MagicMock())
with (
patch.object(server.config, "is_hosted", True),
_request_context(user_id="user-1"),
pytest.raises(RuntimeError, match="protocol 2.0.0"),
):
await server._handle_response( # pyright: ignore[reportPrivateUsage]
request,
context,
asyncio.Event(),
)
async def test_previous_response_requires_existing_agent_session(self) -> None:
agent = _make_agent()
server = _make_server(agent, session_store=SessionStore())
@@ -517,7 +464,7 @@ class TestAgentSessionPersistence:
provider = server._session_storage_provider # pyright: ignore[reportPrivateUsage]
assert provider is not None
session_store = provider.get_store(config=server.config)
session_store = provider.get_store(config=server.config, platform_context=get_request_context())
assert session_store is not None
first_session = await session_store.get(first.json()["id"])
second_session = await session_store.get(second.json()["id"])
@@ -1483,9 +1430,8 @@ class TestOutputItemToMessage:
async def test_mcp_approval_request(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemMcpApprovalRequest
storage = InMemoryFunctionApprovalStore()
saved = _make_function_approval_request_content(request_id="apr-1")
await storage.save_approval_request("apr-1", saved)
storage = _function_approval_store(saved)
item = OutputItemMcpApprovalRequest({
"type": "mcp_approval_request",
@@ -1501,9 +1447,8 @@ class TestOutputItemToMessage:
async def test_mcp_approval_response(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemMcpApprovalResponseResource
storage = InMemoryFunctionApprovalStore()
saved = _make_function_approval_request_content(request_id="apr-1")
await storage.save_approval_request("apr-1", saved)
storage = _function_approval_store(saved)
item = OutputItemMcpApprovalResponseResource({
"type": "mcp_approval_response",
@@ -1993,9 +1938,8 @@ class TestItemToMessage:
async def test_mcp_approval_request(self) -> None:
from azure.ai.agentserver.responses.models import ItemMcpApprovalRequest
storage = InMemoryFunctionApprovalStore()
saved = _make_function_approval_request_content(request_id="apr-1")
await storage.save_approval_request("apr-1", saved)
storage = _function_approval_store(saved)
item = ItemMcpApprovalRequest({
"type": "mcp_approval_request",
@@ -2012,9 +1956,8 @@ class TestItemToMessage:
async def test_mcp_approval_response(self) -> None:
from azure.ai.agentserver.responses.models import MCPApprovalResponse
storage = InMemoryFunctionApprovalStore()
saved = _make_function_approval_request_content(request_id="apr-1")
await storage.save_approval_request("apr-1", saved)
storage = _function_approval_store(saved)
item = MCPApprovalResponse({
"type": "mcp_approval_response",
@@ -3244,39 +3187,14 @@ class TestMultiTurnMixedContent:
# region Function approval round-trip
class TestFunctionApprovalStore:
"""Unit tests for the function approval storage classes."""
async def test_in_memory_save_and_load(self) -> None:
storage = InMemoryFunctionApprovalStore()
request = _make_function_approval_request_content(request_id="apr_1")
await storage.save_approval_request("apr_1", request)
loaded = await storage.load_approval_request("apr_1")
assert loaded.type == "function_approval_request"
assert loaded.id == "apr_1"
async def test_in_memory_duplicate_save_raises(self) -> None:
storage = InMemoryFunctionApprovalStore()
request = _make_function_approval_request_content(request_id="apr_1")
await storage.save_approval_request("apr_1", request)
with pytest.raises(ValueError, match="already exists"):
await storage.save_approval_request("apr_1", request)
async def test_in_memory_missing_load_raises(self) -> None:
storage = InMemoryFunctionApprovalStore()
with pytest.raises(KeyError):
await storage.load_approval_request("missing")
class TestFunctionApprovalConversion:
"""Tests for the approval-aware paths in `_item_to_message` / `_output_item_to_message`."""
async def test_output_item_mcp_approval_request_loads_from_storage(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemMcpApprovalRequest
storage = InMemoryFunctionApprovalStore()
saved = _make_function_approval_request_content(request_id="apr-1")
await storage.save_approval_request("apr-1", saved)
storage = _function_approval_store(saved)
item = OutputItemMcpApprovalRequest({
"type": "mcp_approval_request",
@@ -3311,9 +3229,8 @@ class TestFunctionApprovalConversion:
async def test_output_item_mcp_approval_response_resolves_to_approval_response(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemMcpApprovalResponseResource
storage = InMemoryFunctionApprovalStore()
saved = _make_function_approval_request_content(request_id="apr-1")
await storage.save_approval_request("apr-1", saved)
storage = _function_approval_store(saved)
item = OutputItemMcpApprovalResponseResource({
"type": "mcp_approval_response",
@@ -3346,9 +3263,8 @@ class TestFunctionApprovalConversion:
async def test_input_item_mcp_approval_request_loads_from_storage(self) -> None:
from azure.ai.agentserver.responses.models import ItemMcpApprovalRequest
storage = InMemoryFunctionApprovalStore()
saved = _make_function_approval_request_content(request_id="apr-1")
await storage.save_approval_request("apr-1", saved)
storage = _function_approval_store(saved)
item = ItemMcpApprovalRequest({
"type": "mcp_approval_request",
@@ -3365,9 +3281,8 @@ class TestFunctionApprovalConversion:
async def test_input_item_mcp_approval_response_resolves_to_approval_response(self) -> None:
from azure.ai.agentserver.responses.models import MCPApprovalResponse
storage = InMemoryFunctionApprovalStore()
saved = _make_function_approval_request_content(request_id="apr-1")
await storage.save_approval_request("apr-1", saved)
storage = _function_approval_store(saved)
item = MCPApprovalResponse({
"type": "mcp_approval_response",
@@ -3410,7 +3325,7 @@ class TestFunctionApprovalRoundTrip:
# Storage must contain a saved entry under the emitted request id.
loaded = await server._function_approval_storage_provider.get_store( # pyright: ignore[reportPrivateUsage]
config=server.config
config=server.config, platform_context=get_request_context()
).load_approval_request(approval_request_id)
assert loaded.type == "function_approval_request"
assert loaded.function_call is not None
@@ -3440,7 +3355,7 @@ class TestFunctionApprovalRoundTrip:
assert approval_request_id is not None
loaded = await server._function_approval_storage_provider.get_store( # pyright: ignore[reportPrivateUsage]
config=server.config
config=server.config, platform_context=get_request_context()
).load_approval_request(approval_request_id)
assert loaded.type == "function_approval_request"
@@ -4283,7 +4198,7 @@ class TestWorkflowAgentHosting:
# ``function_call``) must be persisted under that id so the next
# turn can reconstruct it.
loaded = await server._function_approval_storage_provider.get_store( # pyright: ignore[reportPrivateUsage]
config=server.config
config=server.config, platform_context=get_request_context()
).load_approval_request(approval_request_id)
assert loaded.type == "function_approval_request"
assert loaded.function_call is not None
@@ -4313,7 +4228,7 @@ class TestWorkflowAgentHosting:
assert approval_request_id is not None
loaded = await server._function_approval_storage_provider.get_store( # pyright: ignore[reportPrivateUsage]
config=server.config
config=server.config, platform_context=get_request_context()
).load_approval_request(approval_request_id)
assert loaded.type == "function_approval_request"
assert mock_agent.run_count == 1
@@ -4333,32 +4248,39 @@ class TestWorkflowAgentHosting:
final_text="done with approval",
)
server = _make_server(workflow_agent)
checkpoint_provider = server._checkpoint_storage_provider # pyright: ignore[reportPrivateUsage]
first = await _post(server, stream=False)
assert first.status_code == 200
first_body = first.json()
first_response_id = first_body["id"]
approval_items = [it for it in first_body["output"] if it["type"] == "mcp_approval_request"]
assert len(approval_items) == 1
approval_request_id = approval_items[0]["id"]
assert mock_agent.run_count == 1
with patch.object(checkpoint_provider, "get_store", wraps=checkpoint_provider.get_store) as get_store:
first = await _post(server, stream=False)
assert first.status_code == 200
first_body = first.json()
first_response_id = first_body["id"]
approval_items = [it for it in first_body["output"] if it["type"] == "mcp_approval_request"]
assert len(approval_items) == 1
approval_request_id = approval_items[0]["id"]
assert mock_agent.run_count == 1
second_payload: dict[str, Any] = {
"model": "test-model",
"input": [
{
"type": "mcp_approval_response",
"approval_request_id": approval_request_id,
"approve": True,
}
],
"stream": False,
"previous_response_id": first_response_id,
}
second = await _post_json(server, second_payload)
second_payload: dict[str, Any] = {
"model": "test-model",
"input": [
{
"type": "mcp_approval_response",
"approval_request_id": approval_request_id,
"approve": True,
}
],
"stream": False,
"previous_response_id": first_response_id,
}
second = await _post_json(server, second_payload)
assert second.status_code == 200
second_body = second.json()
assert second_body["status"] == "completed"
assert [call.kwargs["context_id"] for call in get_store.call_args_list] == [
first_response_id,
second_body["id"],
first_response_id,
]
# The inner agent must have been resumed (restore replay + new turn).
# Restore call is a no-op for the mock (no input); the new-turn call
@@ -5,8 +5,8 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import AgentSession, Content, SessionStore, WorkflowCheckpoint, WorkflowCheckpointException
from azure.ai.agentserver.core import AgentConfig
from agent_framework import AgentSession, Content, WorkflowCheckpoint, WorkflowCheckpointException
from azure.ai.agentserver.core import AgentConfig, FoundryAgentRequestContext
from azure.ai.agentserver.core.storage import FoundryStorageConflictError
from agent_framework_foundry_hosting import ContextScopedStoreProvider, StoreProvider
@@ -17,7 +17,6 @@ from agent_framework_foundry_hosting._state_store import (
FoundryCheckpointStore,
FoundryFunctionApprovalStore,
FunctionApprovalStoreProvider,
InMemoryFunctionApprovalStore,
)
@@ -60,6 +59,10 @@ def _config(*, is_hosted: bool) -> AgentConfig:
)
def _platform_context(call_id: str = "call-1", user_id: str = "user-1") -> FoundryAgentRequestContext:
return FoundryAgentRequestContext(call_id=call_id, user_id=user_id)
def test_storage_providers_use_public_abstraction() -> None:
assert issubclass(CheckpointStoreProvider, ContextScopedStoreProvider)
assert not issubclass(CheckpointStoreProvider, StoreProvider)
@@ -75,11 +78,11 @@ async def test_save_uses_context_scoped_store() -> None:
"agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create",
new=AsyncMock(return_value=store),
) as get_or_create:
result = await FoundryCheckpointStore("context-1").save(checkpoint)
result = await FoundryCheckpointStore("context-1", _platform_context()).save(checkpoint)
assert result == "checkpoint-1"
get_or_create.assert_awaited_once_with("checkpoints/context-1", user_isolation=True)
store.set_item.assert_awaited_once_with("checkpoint-1", checkpoint.to_dict())
get_or_create.assert_awaited_once_with("checkpoints/context-1", user_isolation=True, user_id="user-1")
store.set_item.assert_awaited_once_with("checkpoint-1", checkpoint.to_dict(), call_id="call-1")
async def test_load_returns_checkpoint() -> None:
@@ -91,9 +94,10 @@ async def test_load_returns_checkpoint() -> None:
"agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create",
new=AsyncMock(return_value=store),
):
result = await FoundryCheckpointStore("context-1").load("checkpoint-1")
result = await FoundryCheckpointStore("context-1", _platform_context()).load("checkpoint-1")
assert result == checkpoint
store.get_item.assert_awaited_once_with("checkpoint-1", call_id="call-1")
async def test_load_raises_for_missing_checkpoint() -> None:
@@ -107,7 +111,7 @@ async def test_load_raises_for_missing_checkpoint() -> None:
),
pytest.raises(WorkflowCheckpointException, match="No checkpoint found with ID missing"),
):
await FoundryCheckpointStore("context-1").load("missing")
await FoundryCheckpointStore("context-1", _platform_context()).load("missing")
async def test_list_checkpoints_paginates_and_filters_by_workflow() -> None:
@@ -134,11 +138,14 @@ async def test_list_checkpoints_paginates_and_filters_by_workflow() -> None:
"agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create",
new=AsyncMock(return_value=store),
):
result = await FoundryCheckpointStore("context-1").list_checkpoints(workflow_name="workflow")
result = await FoundryCheckpointStore("context-1", _platform_context()).list_checkpoints(
workflow_name="workflow"
)
assert result == [matching]
assert store.list_keys.await_args_list[0].kwargs == {"after": None}
assert store.list_keys.await_args_list[1].kwargs == {"after": "cursor-1"}
assert store.list_keys.await_args_list[0].kwargs == {"after": None, "call_id": "call-1"}
assert store.list_keys.await_args_list[1].kwargs == {"after": "cursor-1", "call_id": "call-1"}
assert all(call.kwargs == {"call_id": "call-1"} for call in store.get_item.await_args_list)
@pytest.mark.parametrize(("deleted_id", "expected"), [("item-id", True), (None, False)])
@@ -150,13 +157,14 @@ async def test_delete_reports_whether_checkpoint_existed(deleted_id: str | None,
"agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create",
new=AsyncMock(return_value=store),
):
result = await FoundryCheckpointStore("context-1").delete("checkpoint-1")
result = await FoundryCheckpointStore("context-1", _platform_context()).delete("checkpoint-1")
assert result is expected
store.delete_item.assert_awaited_once_with("checkpoint-1", call_id="call-1")
async def test_get_latest_uses_timestamp_and_list_ids_filters() -> None:
storage = FoundryCheckpointStore("context-1")
storage = FoundryCheckpointStore("context-1", _platform_context())
older = _checkpoint("older", timestamp="2026-01-01T00:00:00+00:00")
newer = _checkpoint("newer", timestamp="2026-01-02T00:00:00+00:00")
storage.list_checkpoints = AsyncMock(return_value=[newer, older]) # zuban:ignore
@@ -166,33 +174,36 @@ async def test_get_latest_uses_timestamp_and_list_ids_filters() -> None:
async def test_get_latest_returns_none_when_no_checkpoints_exist() -> None:
storage = FoundryCheckpointStore("context-1")
storage = FoundryCheckpointStore("context-1", _platform_context())
storage.list_checkpoints = AsyncMock(return_value=[]) # zuban:ignore
assert await storage.get_latest(workflow_name="workflow") is None
@pytest.mark.parametrize("is_hosted", [True, False])
def test_checkpoint_storage_provider_caches_storage_by_context(is_hosted: bool) -> None:
def test_checkpoint_storage_provider_creates_request_scoped_storage(is_hosted: bool) -> None:
provider = CheckpointStoreProvider()
config = _config(is_hosted=is_hosted)
first_context = _platform_context("call-1")
second_context = _platform_context("call-2")
first = provider.get_store(config=config, context_id="context-1")
second = provider.get_store(config=config, context_id="context-2")
first = provider.get_store(config=config, context_id="context-1", platform_context=first_context)
second = provider.get_store(config=config, context_id="context-1", platform_context=second_context)
if is_hosted:
assert type(first) is FoundryCheckpointStore
assert type(second) is FoundryCheckpointStore
assert provider.get_store(config=config, context_id="context-1") is first
assert type(first) is FoundryCheckpointStore
assert type(second) is FoundryCheckpointStore
assert second is not first
assert first.platform_context is first_context
assert second.platform_context is second_context
@pytest.mark.parametrize(
"create_store",
[
lambda: FoundryCheckpointStore(""),
lambda: CheckpointStoreProvider().get_store(config=_config(is_hosted=True), context_id=""),
lambda: FoundryCheckpointStore("", _platform_context()),
lambda: CheckpointStoreProvider().get_store(
config=_config(is_hosted=True), context_id="", platform_context=_platform_context()
),
],
)
def test_checkpoint_stores_require_context_id(create_store: Callable[[], Any]) -> None:
@@ -219,13 +230,14 @@ async def test_save_and_load_function_approval_request() -> None:
"agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create",
new=AsyncMock(return_value=store),
) as get_or_create:
storage = FoundryFunctionApprovalStore()
storage = FoundryFunctionApprovalStore(_platform_context())
await storage.save_approval_request("approval-1", request)
loaded = await storage.load_approval_request("approval-1")
assert get_or_create.await_count == 2
get_or_create.assert_awaited_with("function_approvals", user_isolation=True)
store.create_item.assert_awaited_once_with("approval-1", request.to_dict())
get_or_create.assert_awaited_with("function_approvals", user_isolation=True, user_id="user-1")
store.create_item.assert_awaited_once_with("approval-1", request.to_dict(), call_id="call-1")
store.get_item.assert_awaited_once_with("approval-1", call_id="call-1")
assert loaded == request
function_call = loaded.function_call
assert function_call is not None
@@ -244,7 +256,9 @@ async def test_save_duplicate_function_approval_request_raises() -> None:
),
pytest.raises(ValueError, match="Approval request with ID 'approval-1' already exists"),
):
await FoundryFunctionApprovalStore().save_approval_request("approval-1", _approval_request("approval-1"))
await FoundryFunctionApprovalStore(_platform_context()).save_approval_request(
"approval-1", _approval_request("approval-1")
)
async def test_load_missing_function_approval_request_raises() -> None:
@@ -258,39 +272,32 @@ async def test_load_missing_function_approval_request_raises() -> None:
),
pytest.raises(KeyError, match="Approval request with ID 'missing' does not exist"),
):
await FoundryFunctionApprovalStore().load_approval_request("missing")
await FoundryFunctionApprovalStore(_platform_context()).load_approval_request("missing")
@pytest.mark.parametrize(
("is_hosted", "expected_type"),
[(True, FoundryFunctionApprovalStore), (False, InMemoryFunctionApprovalStore)],
)
def test_function_approval_storage_provider_selects_backend(
is_hosted: bool,
expected_type: type[FoundryFunctionApprovalStore] | type[InMemoryFunctionApprovalStore],
) -> None:
@pytest.mark.parametrize("is_hosted", [True, False])
def test_function_approval_storage_provider_uses_foundry_store(is_hosted: bool) -> None:
provider = FunctionApprovalStoreProvider()
config = _config(is_hosted=is_hosted)
platform_context = _platform_context()
storage = provider.get_store(config=config)
storage = provider.get_store(config=config, platform_context=platform_context)
assert type(storage) is expected_type
assert provider.get_store(config=config) is storage
assert type(storage) is FoundryFunctionApprovalStore
assert storage.platform_context is platform_context
def test_function_approval_storage_provider_creates_only_requested_backend() -> None:
with (
patch("agent_framework_foundry_hosting._state_store.FoundryFunctionApprovalStore") as foundry_storage_type,
patch("agent_framework_foundry_hosting._state_store.InMemoryFunctionApprovalStore") as in_memory_storage_type,
):
def test_function_approval_storage_provider_creates_request_scoped_storage() -> None:
with patch("agent_framework_foundry_hosting._state_store.FoundryFunctionApprovalStore") as storage_type:
provider = FunctionApprovalStoreProvider()
config = _config(is_hosted=True)
storage = provider.get_store(config=config)
config = _config(is_hosted=False)
first_context = _platform_context("call-1")
second_context = _platform_context("call-2")
provider.get_store(config=config, platform_context=first_context)
provider.get_store(config=config, platform_context=second_context)
assert provider.get_store(config=config) is storage
foundry_storage_type.assert_called_once_with()
in_memory_storage_type.assert_not_called()
assert storage_type.call_args_list[0].args == (first_context,)
assert storage_type.call_args_list[1].args == (second_context,)
async def test_set_agent_session_uses_scoped_store() -> None:
@@ -302,10 +309,10 @@ async def test_set_agent_session_uses_scoped_store() -> None:
"agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create",
new=AsyncMock(return_value=store),
) as get_or_create:
await FoundryAgentSessionStore().set("storage-session-1", session)
await FoundryAgentSessionStore(_platform_context()).set("storage-session-1", session)
get_or_create.assert_awaited_once_with("agent_sessions", user_isolation=True)
store.set_item.assert_awaited_once_with("storage-session-1", session.to_dict())
get_or_create.assert_awaited_once_with("agent_sessions", user_isolation=True, user_id="user-1")
store.set_item.assert_awaited_once_with("storage-session-1", session.to_dict(), call_id="call-1")
async def test_get_agent_session_returns_deserialized_session() -> None:
@@ -318,13 +325,13 @@ async def test_get_agent_session_returns_deserialized_session() -> None:
"agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create",
new=AsyncMock(return_value=store),
) as get_or_create:
result = await FoundryAgentSessionStore().get("storage-session-1")
result = await FoundryAgentSessionStore(_platform_context()).get("storage-session-1")
assert result is not None
assert result.to_dict() == session.to_dict()
assert result is not session
get_or_create.assert_awaited_once_with("agent_sessions", user_isolation=True)
store.get_item.assert_awaited_once_with("storage-session-1")
get_or_create.assert_awaited_once_with("agent_sessions", user_isolation=True, user_id="user-1")
store.get_item.assert_awaited_once_with("storage-session-1", call_id="call-1")
async def test_get_missing_agent_session_returns_none() -> None:
@@ -335,7 +342,7 @@ async def test_get_missing_agent_session_returns_none() -> None:
"agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create",
new=AsyncMock(return_value=store),
):
result = await FoundryAgentSessionStore().get("missing")
result = await FoundryAgentSessionStore(_platform_context()).get("missing")
assert result is None
@@ -347,38 +354,31 @@ async def test_delete_agent_session_is_idempotent() -> None:
"agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create",
new=AsyncMock(return_value=store),
):
await FoundryAgentSessionStore().delete("storage-session-1")
await FoundryAgentSessionStore(_platform_context()).delete("storage-session-1")
store.delete_item.assert_awaited_once_with("storage-session-1")
store.delete_item.assert_awaited_once_with("storage-session-1", call_id="call-1")
@pytest.mark.parametrize(
("is_hosted", "expected_type"),
[(True, FoundryAgentSessionStore), (False, SessionStore)],
)
def test_agent_session_storage_provider_selects_backend(
is_hosted: bool,
expected_type: type[FoundryAgentSessionStore] | type[SessionStore],
) -> None:
@pytest.mark.parametrize("is_hosted", [True, False])
def test_agent_session_storage_provider_uses_foundry_store(is_hosted: bool) -> None:
provider = AgentSessionStoreProvider()
config = _config(is_hosted=is_hosted)
platform_context = _platform_context()
storage = provider.get_store(config=config)
storage = provider.get_store(config=config, platform_context=platform_context)
assert type(storage) is expected_type
assert provider.get_store(config=config) is storage
assert type(storage) is FoundryAgentSessionStore
assert storage.platform_context is platform_context
def test_agent_session_storage_provider_creates_only_requested_backend() -> None:
with (
patch("agent_framework_foundry_hosting._state_store.FoundryAgentSessionStore") as foundry_storage_type,
patch("agent_framework_foundry_hosting._state_store.SessionStore") as in_memory_storage_type,
):
def test_agent_session_storage_provider_creates_request_scoped_storage() -> None:
with patch("agent_framework_foundry_hosting._state_store.FoundryAgentSessionStore") as storage_type:
provider = AgentSessionStoreProvider()
config = _config(is_hosted=False)
storage = provider.get_store(config=config)
first_context = _platform_context("call-1")
second_context = _platform_context("call-2")
provider.get_store(config=config, platform_context=first_context)
provider.get_store(config=config, platform_context=second_context)
assert provider.get_store(config=config) is storage
foundry_storage_type.assert_not_called()
in_memory_storage_type.assert_called_once_with()
assert storage_type.call_args_list[0].args == (first_context,)
assert storage_type.call_args_list[1].args == (second_context,)