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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense for CheckpointStoreProvider to accept and forward allowed_checkpoint_types here? ResponsesHostServer creates stores through this provider, so hosted apps cannot use the new per-store option on the normal path and must either register types process-wide or replace the whole provider. Threading the list through CheckpointStoreProvider.__init__ would keep the new configuration reachable without changing its behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — you're right, and it made the change worse than it needed to be. With the option only on the store, a hosted app on the default path had no way to reach it, so its only recourse for a checkpoint carrying its own type was process-wide registration or replacing the provider.

Pushed 9fad234: CheckpointStoreProvider.__init__ takes allowed_checkpoint_types and forwards it to every store it creates. Keyword-only with a None default, so CheckpointStoreProvider() behaves exactly as before.

Two tests added: one asserting a type named through the provider survives a load, and one asserting a provider built with no arguments still restricts. The first fails without the change with TypeError: CheckpointStoreProvider() takes no arguments, which is the gap you described.

Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,30 @@ class FoundryCheckpointStore:

DEFAULT_ROOT_SCOPE = "checkpoints"

def __init__(self, context_id: str, platform_context: FoundryAgentRequestContext) -> None:
def __init__(
self,
context_id: str,
platform_context: FoundryAgentRequestContext,
*,
allowed_checkpoint_types: list[str] | None = None,
) -> 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.
allowed_checkpoint_types: Additional types (beyond the built-in safe set
and framework types) that are permitted during checkpoint
deserialization. Each entry should be a ``"module:qualname"``
string (e.g., ``"my_app.models:MyState"``).
"""
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
self._allowed_types: frozenset[str] = frozenset(allowed_checkpoint_types or [])

async def _get_store(self) -> FoundryStateStore:
return await FoundryStateStore.get_or_create(
Expand Down Expand Up @@ -131,7 +142,7 @@ async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
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))
return WorkflowCheckpoint.from_dict(decode_checkpoint_value(item.value, allowed_types=self._allowed_types))

async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]:
"""List all workflow checkpoints for a given workflow name."""
Expand All @@ -147,7 +158,9 @@ async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoi
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))
checkpoint = WorkflowCheckpoint.from_dict(
decode_checkpoint_value(item.value, allowed_types=self._allowed_types)
)
if checkpoint.workflow_name == workflow_name:
checkpoints.append(checkpoint)
if not page.has_more or page.last_id is None:
Expand Down Expand Up @@ -182,6 +195,18 @@ class CheckpointStoreProvider(ContextScopedStoreProvider[CheckpointStorage]):
This defaults to using the `FoundryCheckpointStore` in all environments.
"""

def __init__(self, *, allowed_checkpoint_types: list[str] | None = None) -> None:
"""Initialize the provider.

Args:
allowed_checkpoint_types: Additional types (beyond the built-in safe set
and framework types) that are permitted during checkpoint
deserialization, forwarded to every store this provider creates.
Each entry should be a ``"module:qualname"`` string
(e.g., ``"my_app.models:MyState"``).
"""
self._allowed_checkpoint_types = allowed_checkpoint_types

def get_store(
self,
*,
Expand All @@ -193,7 +218,11 @@ def get_store(
if not context_id:
raise ValueError("context_id must be provided to get a checkpoint store.")

return FoundryCheckpointStore(context_id, platform_context)
return FoundryCheckpointStore(
context_id,
platform_context,
allowed_checkpoint_types=self._allowed_checkpoint_types,
)


# endregion Checkpoint persistence
Expand Down
126 changes: 126 additions & 0 deletions python/packages/foundry_hosting/tests/test_state_store.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Callable
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from agent_framework import AgentSession, Content, WorkflowCheckpoint, WorkflowCheckpointException
from agent_framework._workflows._checkpoint_encoding import encode_checkpoint_value
from azure.ai.agentserver.core import AgentConfig, FoundryAgentRequestContext
from azure.ai.agentserver.core.storage import FoundryStorageConflictError

Expand All @@ -20,6 +22,13 @@
)


@dataclass
class _NotAllowed:
"""A type outside the built-in safe set, standing in for an application type."""

value: int


def _checkpoint(
checkpoint_id: str, *, workflow_name: str = "workflow", timestamp: str = "2026-01-01T00:00:00+00:00"
) -> WorkflowCheckpoint:
Expand Down Expand Up @@ -100,6 +109,123 @@ async def test_load_returns_checkpoint() -> None:
store.get_item.assert_awaited_once_with("checkpoint-1", call_id="call-1")


async def test_load_restricts_checkpoint_deserialization() -> None:
"""A checkpoint value naming a type outside the allow set is refused.

The file and Cosmos checkpoint stores both restrict deserialization this
way; this store reaches the same decoder, so it restricts it too.
"""
store = _store()
checkpoint = _checkpoint("checkpoint-1")
value = checkpoint.to_dict()
value["state"] = encode_checkpoint_value({"payload": _NotAllowed(7)})
store.get_item = AsyncMock(return_value=SimpleNamespace(value=value))

with (
patch(
"agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create",
new=AsyncMock(return_value=store),
),
pytest.raises(WorkflowCheckpointException),
):
await FoundryCheckpointStore("context-1", _platform_context()).load("checkpoint-1")


async def test_load_accepts_a_declared_checkpoint_type() -> None:
"""A caller can still name the types its checkpoints carry."""
store = _store()
checkpoint = _checkpoint("checkpoint-1")
value = checkpoint.to_dict()
value["state"] = encode_checkpoint_value({"payload": _NotAllowed(7)})
store.get_item = AsyncMock(return_value=SimpleNamespace(value=value))

with patch(
"agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create",
new=AsyncMock(return_value=store),
):
result = await FoundryCheckpointStore(
"context-1",
_platform_context(),
allowed_checkpoint_types=[f"{_NotAllowed.__module__}:{_NotAllowed.__qualname__}"],
).load("checkpoint-1")

assert result.state["payload"].value == 7


async def test_list_checkpoints_restricts_checkpoint_deserialization() -> None:
store = _store()
checkpoint = _checkpoint("checkpoint-1")
value = checkpoint.to_dict()
value["state"] = encode_checkpoint_value({"payload": _NotAllowed(7)})
store.list_keys = AsyncMock(
return_value=SimpleNamespace(keys=[SimpleNamespace(key="checkpoint-1")], has_more=False, last_id=None)
)
store.get_item = AsyncMock(return_value=SimpleNamespace(value=value))

with (
patch(
"agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create",
new=AsyncMock(return_value=store),
),
pytest.raises(WorkflowCheckpointException),
):
await FoundryCheckpointStore("context-1", _platform_context()).list_checkpoints(workflow_name="workflow")


async def test_provider_forwards_allowed_checkpoint_types() -> None:
"""A hosted app reaches the option through the provider it actually gets.

`ResponsesHostServer` builds a `CheckpointStoreProvider` itself on the default
path, so an option only settable on the store would be out of reach there.
"""
store = _store()
checkpoint = _checkpoint("checkpoint-1")
value = checkpoint.to_dict()
value["state"] = encode_checkpoint_value({"payload": _NotAllowed(7)})
store.get_item = AsyncMock(return_value=SimpleNamespace(value=value))

provider = CheckpointStoreProvider(
allowed_checkpoint_types=[f"{_NotAllowed.__module__}:{_NotAllowed.__qualname__}"]
)
storage = provider.get_store(
config=MagicMock(),
context_id="context-1",
platform_context=_platform_context(),
)

with patch(
"agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create",
new=AsyncMock(return_value=store),
):
result = await storage.load("checkpoint-1")

assert result.state["payload"].value == 7


async def test_provider_restricts_by_default() -> None:
"""Without the option the provider's stores restrict, as before."""
store = _store()
checkpoint = _checkpoint("checkpoint-1")
value = checkpoint.to_dict()
value["state"] = encode_checkpoint_value({"payload": _NotAllowed(7)})
store.get_item = AsyncMock(return_value=SimpleNamespace(value=value))

storage = CheckpointStoreProvider().get_store(
config=MagicMock(),
context_id="context-1",
platform_context=_platform_context(),
)

with (
patch(
"agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create",
new=AsyncMock(return_value=store),
),
pytest.raises(WorkflowCheckpointException),
):
await storage.load("checkpoint-1")


async def test_load_raises_for_missing_checkpoint() -> None:
store = _store()
store.get_item = AsyncMock(return_value=None)
Expand Down
Loading