From 112022081edd44b2549771a5c1d056a12c2cd759 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 25 Sep 2026 09:56:22 +0200 Subject: [PATCH 1/4] fix(python): isolate Foundry-hosted MAF state by sandbox --- python/packages/foundry_hosting/README.md | 43 +++- .../__init__.py | 3 + .../_invocations.py | 4 +- .../_responses.py | 10 + .../agent_framework_foundry_hosting/_scope.py | 57 +++++ .../_state_store.py | 105 ++++++-- .../tests/test_foundry_hosting_imports.py | 1 + .../foundry_hosting/tests/test_invocations.py | 18 ++ .../foundry_hosting/tests/test_responses.py | 34 ++- .../foundry_hosting/tests/test_scope.py | 66 +++++ .../foundry_hosting/tests/test_state_store.py | 241 +++++++++++++++++- .../responses/basic/README.md | 9 + 12 files changed, 560 insertions(+), 31 deletions(-) create mode 100644 python/packages/foundry_hosting/agent_framework_foundry_hosting/_scope.py create mode 100644 python/packages/foundry_hosting/tests/test_scope.py diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index 8fc3a122fe4..2cbd6ba2664 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -96,10 +96,38 @@ encoding of the store name. For example: ### 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. +Hosted requests take their sandbox session ID, user ID, and call ID from the trusted +AgentServer request context. All three are required; IDs in a caller's Responses or +Invocations payload cannot stand in for missing platform identity. The exported +`FoundryRequestScope.from_context(config, platform_context)` validates this identity. +Its `storage_key` is a bounded hash of the framed user and sandbox IDs, not a caller +conversation or response ID. + +| Identifier | Purpose | +| --- | --- | +| Foundry session ID | Platform sandbox for the hosted request and its MAF state. | +| Platform user ID / call ID | User isolation and per-request storage authorization/correlation. A call ID is **not** a conversation ID. | +| Responses `response.id`, `previous_response_id`, `conversation` | Caller-visible continuation and conversation IDs, used as item keys *within* the sandbox. | +| MAF `AgentSession.session_id` / `service_session_id` | Inner agent state and optional downstream service continuation; neither identifies the Foundry sandbox. | +| Workflow checkpoint ID | Inner workflow state within a checkpoint context, not a Foundry session ID. | + +The default hosted MAF session, checkpoint, and approval stores use a `v2` namespace +derived from the hashed platform identity, with `user_isolation=True` and an explicit +platform `call_id` on each item operation. Checkpoint context IDs are hashed as well. +The same user in two hosted sandboxes cannot read the other's default MAF state. +Locally, the existing single-user store names and file-based fallback remain unchanged; +direct store constructors without a trusted `scope` also retain their existing local +behavior. Applications that supply custom store providers must implement equivalent +hosted user and sandbox isolation. + +**Existing hosted state is not migrated.** The default stores never fall back to +legacy unscoped `agent_sessions`, `checkpoints/`, or +`function_approvals` data: an old MAF session, workflow checkpoint, or pending approval +cannot be resumed through the new default hosted stores. Start a fresh Responses +conversation rather than reusing an old `previous_response_id` or conversation ID; +the separate AgentServer response store is not migrated by this change. Recovering +old state requires a separately designed migration that verifies the original user's +and sandbox's ownership; reading unscoped data by user alone is not safe. ### Agent Sessions @@ -108,6 +136,13 @@ durably. By default they use `FoundryAgentSessionStore`, backed by Foundry stora and file-based storage locally. Responses sessions use the `agent_sessions` logical store; Invocations sessions use the separate `invocation_sessions` store. +Loaded MAF sessions are saved with an ETag condition. A competing turn that has +already advanced the same conversation causes a visible persistence failure instead +of silently overwriting its state. New hosted session keys are created only if absent; +turns using `previous_response_id` write their own new response ID, without applying +the predecessor's ETag to a different key. Local callers can still upsert directly +without first loading a session. + 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. diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/__init__.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/__init__.py index 43f861919c1..de96da16c4b 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/__init__.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/__init__.py @@ -6,6 +6,7 @@ if TYPE_CHECKING: from ._invocations import InvocationsHostServer from ._responses import ResponsesHostServer + from ._scope import FoundryRequestScope from ._state_store import ( AgentSessionStoreProvider, CheckpointStoreProvider, @@ -31,6 +32,7 @@ "FoundryAgentSessionStore": "._state_store", "FoundryCheckpointStore": "._state_store", "FoundryFunctionApprovalStore": "._state_store", + "FoundryRequestScope": "._scope", "FoundryToolbox": "._toolbox", "FunctionApprovalStore": "._state_store", "FunctionApprovalStoreProvider": "._state_store", @@ -46,6 +48,7 @@ "FoundryAgentSessionStore", "FoundryCheckpointStore", "FoundryFunctionApprovalStore", + "FoundryRequestScope", "FoundryToolbox", "FunctionApprovalStore", "FunctionApprovalStoreProvider", diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py index 1f379d36a17..052eb6aadd0 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py @@ -19,6 +19,7 @@ from ._agent_source import is_agent, resolve_agent, validate_agent_source from ._feature_usage import FeatureIndex +from ._scope import FoundryRequestScope from ._state_store import AgentSessionStoreProvider, StoreProvider logger = logging.getLogger(__name__) @@ -107,7 +108,8 @@ def _partition_key(self) -> str | tuple[str, str]: "The hosted environment is missing session_id or user_id in the request context. " "Please ensure that the request is coming from a valid Foundry platform service." ) - return context.session_id, context.user_id + scope = FoundryRequestScope.from_context(self.config, context) + return scope.session_id, context.user_id if not context.session_id: raise RuntimeError( diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index 325bf264cd1..11adf65ee1f 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -86,6 +86,7 @@ from ._agent_source import is_agent, resolve_agent, validate_agent_source from ._feature_usage import FeatureIndex +from ._scope import FoundryRequestScope from ._state_store import ( AgentSessionStoreProvider, CheckpointStoreProvider, @@ -668,6 +669,15 @@ async def _handle_response( yield response_event_stream.emit_created() yield response_event_stream.emit_in_progress() + if self.config.is_hosted: + try: + FoundryRequestScope.from_context(self.config, get_request_context()) + except RuntimeError as exc: + logger.error("Invalid Foundry hosted request context: %s", exc) + for event in self._emit_failure(response_event_stream, None, exc): + yield event + return + terminal_event: ResponseStreamEvent | None = None agent = await resolve_agent(self._agent_source) configuration = self._configuration or _validate_agent_configuration( diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_scope.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_scope.py new file mode 100644 index 00000000000..62f9d205ac0 --- /dev/null +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_scope.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Trusted request identity for Foundry-hosted state.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass + +from azure.ai.agentserver.core import AgentConfig, FoundryAgentRequestContext + + +@dataclass(frozen=True) +class FoundryRequestScope: + """Separate the platform sandbox and user from caller-facing continuation IDs.""" + + session_id: str + user_id: str | None + call_id: str | None + is_hosted: bool + + @classmethod + def from_context( + cls, + config: AgentConfig, + context: FoundryAgentRequestContext, + *, + local_session_id: str | None = None, + ) -> FoundryRequestScope: + """Resolve identity from the platform context, never from a hosted caller's request. + + Args: + config: AgentServer's configuration for this host. + context: Trusted platform request context. + local_session_id: Fallback session ID for local, non-hosted requests only. + + Raises: + RuntimeError: If a required platform identity is missing. + """ + session_id = context.session_id or (local_session_id if not config.is_hosted else None) + if not session_id: + raise RuntimeError("A Foundry agent session ID is required to handle the request.") + if config.is_hosted and (not context.user_id or not context.call_id): + raise RuntimeError("Foundry hosted requests require a trusted user ID and call ID.") + return cls( + session_id=session_id, + user_id=context.user_id, + call_id=context.call_id, + is_hosted=config.is_hosted, + ) + + @property + def storage_key(self) -> str: + """Hash the framed platform identity so store names reveal neither ID.""" + identity = json.dumps([self.user_id, self.session_id], ensure_ascii=False, separators=(",", ":")) + return hashlib.sha256(identity.encode("utf-8")).hexdigest() diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py index 931342d768a..a1678dc4796 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. +import hashlib from abc import ABC, abstractmethod from datetime import datetime from typing import Generic, Protocol, TypeVar @@ -15,11 +16,32 @@ WorkflowCheckpointException, ) from azure.ai.agentserver.core import AgentConfig, FoundryAgentRequestContext -from azure.ai.agentserver.core.storage import FoundryStateStore, FoundryStorageConflictError +from azure.ai.agentserver.core.storage import ( + FoundryStateStore, + FoundryStorageConflictError, + FoundryStoragePreconditionError, +) + +from ._scope import FoundryRequestScope StoreT = TypeVar("StoreT") +def _store_scope(config: AgentConfig, platform_context: FoundryAgentRequestContext) -> FoundryRequestScope | None: + if not config.is_hosted: + return None + return FoundryRequestScope.from_context(config, platform_context) + + +def _store_name(root: str, scope: FoundryRequestScope | None, context_id: str | None = None) -> str: + if scope is None: + return f"{root}/{context_id}" if context_id is not None else root + name = f"{root}/v2/{scope.storage_key}" + if context_id is not None: + name += f"/{hashlib.sha256(context_id.encode('utf-8')).hexdigest()[:24]}" + return name + + class StoreProvider(ABC, Generic[StoreT]): """Provide store for a hosting environment.""" @@ -78,6 +100,7 @@ def __init__( platform_context: FoundryAgentRequestContext, *, allowed_checkpoint_types: list[str] | None = None, + scope: FoundryRequestScope | None = None, ) -> None: """Initialize a Foundry-scoped checkpoint store for the given context ID. @@ -89,6 +112,7 @@ def __init__( and framework types) that are permitted during checkpoint deserialization. Each entry should be a ``"module:qualname"`` string (e.g., ``"my_app.models:MyState"``). + scope: Trusted request scope for hosted sandbox isolation. """ if not context_id: raise ValueError("context_id must be provided to initialize a FoundryCheckpointStore.") @@ -96,10 +120,11 @@ def __init__( self.context_id = context_id self.platform_context = platform_context self._allowed_types: frozenset[str] = frozenset(allowed_checkpoint_types or []) + self._scope = scope async def _get_store(self) -> FoundryStateStore: return await FoundryStateStore.get_or_create( - f"{self.DEFAULT_ROOT_SCOPE}/{self.context_id}", + _store_name(self.DEFAULT_ROOT_SCOPE, self._scope, self.context_id), user_isolation=True, ) @@ -222,6 +247,7 @@ def get_store( context_id, platform_context, allowed_checkpoint_types=self._allowed_checkpoint_types, + scope=_store_scope(config, platform_context), ) @@ -243,22 +269,24 @@ async def load_approval_request(self, approval_request_id: str) -> Content: class FoundryFunctionApprovalStore: - """Function approval store backed by the `FoundryStateStore`. - - This storage implements a hybrid approach where the checkpoint metadata and structure are - stored in JSON format, while the actual state data (which may contain complex Python objects) - is serialized using pickle and embedded as base64-encoded strings within the JSON. This allows - for human-readable checkpoint files while preserving the ability to store complex Python objects. - """ + """Function approval store backed by the `FoundryStateStore`.""" DEFAULT_ROOT_SCOPE = "function_approvals" - def __init__(self, platform_context: FoundryAgentRequestContext) -> None: + def __init__( + self, + platform_context: FoundryAgentRequestContext, + *, + scope: FoundryRequestScope | None = None, + context_id: str | None = None, + ) -> None: self.platform_context = platform_context + self._scope = scope + self._context_id = context_id async def _get_store(self) -> FoundryStateStore: return await FoundryStateStore.get_or_create( - self.DEFAULT_ROOT_SCOPE, + _store_name(self.DEFAULT_ROOT_SCOPE, self._scope, self._context_id), user_isolation=True, ) @@ -285,9 +313,19 @@ class FunctionApprovalStoreProvider(StoreProvider[FunctionApprovalStore]): This defaults to using the `FoundryFunctionApprovalStore` in all environments. """ - def get_store(self, *, config: AgentConfig, platform_context: FoundryAgentRequestContext) -> FunctionApprovalStore: + def get_store( + self, + *, + config: AgentConfig, + platform_context: FoundryAgentRequestContext, + context_id: str | None = None, + ) -> FunctionApprovalStore: """Get function approval store for the requested hosting environment.""" - return FoundryFunctionApprovalStore(platform_context) + return FoundryFunctionApprovalStore( + platform_context, + scope=_store_scope(config, platform_context), + context_id=context_id, + ) # endregion Function approval persistence @@ -300,21 +338,30 @@ class FoundryAgentSessionStore(SessionStore): DEFAULT_ROOT_SCOPE = "agent_sessions" - def __init__(self, platform_context: FoundryAgentRequestContext, *, store_name: str | None = None) -> None: + def __init__( + self, + platform_context: FoundryAgentRequestContext, + *, + store_name: str | None = None, + scope: FoundryRequestScope | None = None, + ) -> None: """Initialize session storage. Args: platform_context: The request-scoped platform context. store_name: Logical state-store name. Defaults to `agent_sessions`. + scope: Trusted Foundry request scope for sandbox-specific storage. """ if store_name is not None and (not isinstance(store_name, str) or not store_name.strip()): raise ValueError("store_name must be a non-empty string") self.platform_context = platform_context self._store_name = self.DEFAULT_ROOT_SCOPE if store_name is None else store_name + self._scope = scope + self._etags: dict[str, str | None] = {} async def _get_store(self) -> FoundryStateStore: return await FoundryStateStore.get_or_create( - self._store_name, + _store_name(self._store_name, self._scope), user_isolation=True, ) @@ -323,13 +370,33 @@ async def get(self, session_id: str) -> AgentSession | None: async with store: item = await store.get_item(session_id, call_id=self.platform_context.call_id) if item is None: + self._etags[session_id] = None return None + if not item.etag: + raise RuntimeError("Foundry agent session storage returned a loaded session without an ETag.") + self._etags[session_id] = item.etag return AgentSession.from_dict(item.value) 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(), call_id=self.platform_context.call_id) + try: + etag = self._etags.get(session_id) + if etag is not None: + result = await store.set_item( + session_id, session.to_dict(), if_match=etag, call_id=self.platform_context.call_id + ) + elif self._scope is not None or session_id in self._etags: + result = await store.create_item( + session_id, session.to_dict(), call_id=self.platform_context.call_id + ) + else: + result = await store.set_item(session_id, session.to_dict(), call_id=self.platform_context.call_id) + except (FoundryStorageConflictError, FoundryStoragePreconditionError) as exc: + raise RuntimeError("Another request advanced this agent session; reload before writing.") from exc + if not result.etag: + raise RuntimeError("Foundry agent session storage did not return an ETag after saving.") + self._etags[session_id] = result.etag async def delete(self, session_id: str) -> None: store = await self._get_store() @@ -355,7 +422,11 @@ def __init__(self, *, store_name: str | None = None) -> None: def get_store(self, *, config: AgentConfig, platform_context: FoundryAgentRequestContext) -> SessionStore: """Get agent session store for the requested hosting environment.""" - return FoundryAgentSessionStore(platform_context, store_name=self._store_name) + return FoundryAgentSessionStore( + platform_context, + store_name=self._store_name, + scope=_store_scope(config, platform_context), + ) # endregion Agent session persistence diff --git a/python/packages/foundry_hosting/tests/test_foundry_hosting_imports.py b/python/packages/foundry_hosting/tests/test_foundry_hosting_imports.py index 65e2ee8e08c..6cff81931b2 100644 --- a/python/packages/foundry_hosting/tests/test_foundry_hosting_imports.py +++ b/python/packages/foundry_hosting/tests/test_foundry_hosting_imports.py @@ -14,6 +14,7 @@ def test_package_import_is_lazy() -> None: assert "agent_framework_foundry_hosting._invocations" not in sys.modules assert "agent_framework_foundry_hosting._responses" not in sys.modules + assert "agent_framework_foundry_hosting._scope" not in sys.modules assert "agent_framework_foundry_hosting._state_store" not in sys.modules assert "agent_framework_foundry_hosting._toolbox" not in sys.modules """ diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index 0849a96031f..468ffb07a88 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -570,6 +570,15 @@ def test_hosted_returns_composite_key(self) -> None: with _request_context(call_id="call-1", session_id="sess-1", user_id="user-1"): assert server._partition_key() == ("sess-1", "user-1") # pyright: ignore[reportPrivateUsage] + def test_hosted_requires_platform_call_id(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="trusted user ID and call ID"), + ): + server._partition_key() # pyright: ignore[reportPrivateUsage] + async def test_hosted_keys_and_session_ids_preserve_identifier_values(self) -> None: agent = _make_agent(response_text="hi") server = InvocationsHostServer(agent) @@ -749,6 +758,15 @@ async def test_partition_key_failure_returns_500(self) -> None: assert isinstance(response, Response) assert response.status_code == 500 + async def test_hosted_missing_call_id_rejects_before_running_agent(self) -> None: + agent = _make_agent(response_text="hi") + server = InvocationsHostServer(agent) + server.config.is_hosted = True + with _request_context(session_id="sess-1", user_id="user-1"): + response = await server._handle_invoke(_make_request({"message": "Hi"})) # pyright: ignore[reportPrivateUsage] + assert response.status_code == 500 + assert agent.calls == [] + async def test_non_streaming_returns_agent_text(self) -> None: agent = _make_agent(response_text="Hello!") server = InvocationsHostServer(agent) diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index f781bfaa2f4..ab2cfb359db 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -56,7 +56,7 @@ ) from agent_framework.ag_ui import AgentFrameworkAgent, InMemoryAGUIThreadSnapshotStore from agent_framework.openai import OpenAIChatClient -from azure.ai.agentserver.core import get_request_context +from azure.ai.agentserver.core import FoundryAgentRequestContext, get_request_context from azure.ai.agentserver.responses import ( FileResponseStore, InMemoryResponseProvider, @@ -1086,6 +1086,38 @@ async def test_previous_response_requires_existing_agent_session(self) -> None: class TestAgentSessionPersistence: + async def test_hosted_missing_call_id_fails_without_running_agent(self) -> None: + agent = _make_agent() + server = _make_server(agent, session_store=SessionStore()) + server.config.is_hosted = True + request = CreateResponse(model="m", input="hi") + context = ResponseContext(response_id="response-1", mode_flags=MagicMock()) + + with patch( + "agent_framework_foundry_hosting._responses.get_request_context", + return_value=FoundryAgentRequestContext(session_id="sandbox-1", user_id="user-1"), + ): + events = [ + event + async for event in server._handle_response( # pyright: ignore[reportPrivateUsage] + request, context, asyncio.Event() + ) + ] + + types = [event["type"] for event in events if isinstance(event, Mapping)] + assert types[-1] == "response.failed" + assert "response.completed" not in types + failed_events = [ + event for event in events if isinstance(event, Mapping) and event.get("type") == "response.failed" + ] + assert len(failed_events) == 1 + failed_event = cast(Mapping[str, Any], failed_events[0]) + response = cast(Mapping[str, Any], failed_event["response"]) + error = cast(Mapping[str, Any], response["error"]) + assert "trusted user ID and call ID" in error["message"] + agent.run.assert_not_called() + agent.create_session.assert_not_called() + async def test_previous_response_chain_restores_session_state(self) -> None: seen_counts: list[int] = [] seen_session_ids: list[str] = [] diff --git a/python/packages/foundry_hosting/tests/test_scope.py b/python/packages/foundry_hosting/tests/test_scope.py new file mode 100644 index 00000000000..84e41dd7cd5 --- /dev/null +++ b/python/packages/foundry_hosting/tests/test_scope.py @@ -0,0 +1,66 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for trusted Foundry request identity.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from azure.ai.agentserver.core import AgentConfig, FoundryAgentRequestContext + +from agent_framework_foundry_hosting import FoundryRequestScope + + +def _config(*, is_hosted: bool) -> AgentConfig: + config = MagicMock(spec=AgentConfig) + config.is_hosted = is_hosted + return config + + +@pytest.mark.parametrize( + ("session_id", "user_id", "call_id", "error"), + [ + (None, "user", "call", "session ID"), + ("", "user", "call", "session ID"), + ("sandbox", None, "call", "user ID and call ID"), + ("sandbox", "", "call", "user ID and call ID"), + ("sandbox", "user", None, "user ID and call ID"), + ("sandbox", "user", "", "user ID and call ID"), + ], +) +def test_hosted_scope_requires_platform_identity( + session_id: str | None, user_id: str | None, call_id: str | None, error: str +) -> None: + context = FoundryAgentRequestContext(session_id=session_id, user_id=user_id, call_id=call_id) + with pytest.raises(RuntimeError, match=error): + FoundryRequestScope.from_context(_config(is_hosted=True), context, local_session_id="caller-supplied") + + +def test_local_scope_can_use_a_local_session_without_hosted_identity() -> None: + scope = FoundryRequestScope.from_context( + _config(is_hosted=False), + FoundryAgentRequestContext(), + local_session_id="local-session", + ) + assert scope == FoundryRequestScope(session_id="local-session", user_id=None, call_id=None, is_hosted=False) + with pytest.raises(RuntimeError, match="session ID"): + FoundryRequestScope.from_context(_config(is_hosted=False), FoundryAgentRequestContext()) + + +def test_store_key_frames_user_and_sandbox_but_not_call_id() -> None: + config = _config(is_hosted=True) + + def scope(user: str, sandbox: str, call: str) -> FoundryRequestScope: + return FoundryRequestScope.from_context( + config, FoundryAgentRequestContext(user_id=user, session_id=sandbox, call_id=call) + ) + + first = scope("user/a", "b", "call-1") + assert first.storage_key == scope("user/a", "b", "call-2").storage_key + assert first.storage_key != scope("user", "a/b", "call-1").storage_key + assert first.storage_key != scope("user/a", "other", "call-1").storage_key + assert first.storage_key != scope("other", "b", "call-1").storage_key + assert len(first.storage_key) == 64 + int(first.storage_key, 16) + assert "user" not in first.storage_key and "sandbox" not in first.storage_key diff --git a/python/packages/foundry_hosting/tests/test_state_store.py b/python/packages/foundry_hosting/tests/test_state_store.py index 2c6d8389bc5..1aff9e7a6be 100644 --- a/python/packages/foundry_hosting/tests/test_state_store.py +++ b/python/packages/foundry_hosting/tests/test_state_store.py @@ -11,7 +11,7 @@ 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 +from azure.ai.agentserver.core.storage import FoundryStateStore, FoundryStorageConflictError from agent_framework_foundry_hosting import ContextScopedStoreProvider, StoreProvider from agent_framework_foundry_hosting._state_store import ( @@ -46,8 +46,8 @@ def _store() -> MagicMock: store = MagicMock() store.__aenter__ = AsyncMock(return_value=store) store.__aexit__ = AsyncMock(return_value=None) - store.create_item = AsyncMock() - store.set_item = AsyncMock() + store.create_item = AsyncMock(return_value=SimpleNamespace(etag="etag-1")) + store.set_item = AsyncMock(return_value=SimpleNamespace(etag="etag-2")) store.get_item = AsyncMock() store.list_keys = AsyncMock() store.delete_item = AsyncMock() @@ -70,8 +70,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 _platform_context( + call_id: str = "call-1", user_id: str = "user-1", session_id: str = "sandbox-1" +) -> FoundryAgentRequestContext: + return FoundryAgentRequestContext(call_id=call_id, user_id=user_id, session_id=session_id) def test_local_agentserver_state_root_is_test_scoped(tmp_path: Path) -> None: @@ -197,7 +199,7 @@ async def test_provider_forwards_allowed_checkpoint_types() -> None: allowed_checkpoint_types=[f"{_NotAllowed.__module__}:{_NotAllowed.__qualname__}"] ) storage = provider.get_store( - config=MagicMock(), + config=_config(is_hosted=False), context_id="context-1", platform_context=_platform_context(), ) @@ -220,7 +222,7 @@ async def test_provider_restricts_by_default() -> None: store.get_item = AsyncMock(return_value=SimpleNamespace(value=value)) storage = CheckpointStoreProvider().get_store( - config=MagicMock(), + config=_config(is_hosted=False), context_id="context-1", platform_context=_platform_context(), ) @@ -476,7 +478,7 @@ async def test_get_agent_session_returns_deserialized_session() -> None: store = _store() session = AgentSession(session_id="agent-session-1") session.state["turn_count"] = 2 - store.get_item = AsyncMock(return_value=SimpleNamespace(value=session.to_dict())) + store.get_item = AsyncMock(return_value=SimpleNamespace(value=session.to_dict(), etag="etag-1")) with patch( "agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create", @@ -539,3 +541,226 @@ def test_agent_session_storage_provider_creates_request_scoped_storage() -> None assert storage_type.call_args_list[0].args == (first_context,) assert storage_type.call_args_list[1].args == (second_context,) + + +@pytest.mark.parametrize("provider", ["agent", "checkpoint", "approval"]) +@pytest.mark.parametrize( + ("session_id", "user_id", "call_id"), + [ + (None, "user", "call"), + ("sandbox", None, "call"), + ("sandbox", "user", None), + ], +) +def test_hosted_store_providers_reject_missing_platform_identity( + provider: str, session_id: str | None, user_id: str | None, call_id: str | None +) -> None: + context = FoundryAgentRequestContext(session_id=session_id, user_id=user_id, call_id=call_id) + config = _config(is_hosted=True) + with pytest.raises(RuntimeError, match="session ID|user ID and call ID"): + if provider == "agent": + AgentSessionStoreProvider().get_store(config=config, platform_context=context) + elif provider == "checkpoint": + CheckpointStoreProvider().get_store(config=config, context_id="workflow", platform_context=context) + else: + FunctionApprovalStoreProvider().get_store(config=config, platform_context=context) + + +async def test_hosted_store_names_partition_sandbox_and_forward_call_id() -> None: + names: list[str] = [] + store = _store() + + async def get_store(name: str, *, user_isolation: bool) -> MagicMock: + assert user_isolation is True + names.append(name) + return store + + config = _config(is_hosted=True) + contexts = [ + _platform_context(call_id="call-1", session_id="../sandbox/" * 100), + _platform_context(call_id="call-2", session_id="other-sandbox"), + ] + + with patch("agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create", new=get_store): + for context in contexts: + sessions = AgentSessionStoreProvider().get_store(config=config, platform_context=context) + await sessions.set("response-1", AgentSession()) + checkpoints = CheckpointStoreProvider().get_store( + config=config, context_id="../../conversation", platform_context=context + ) + await checkpoints.save(_checkpoint("checkpoint-1")) + approvals = FunctionApprovalStoreProvider().get_store( + config=config, platform_context=context, context_id="workflow" + ) + await approvals.save_approval_request("approval-1", _approval_request("approval-1")) + + assert len(names) == len(set(names)) == 6 + assert all(name.split("/")[1] == "v2" and len(name) <= 128 for name in names) + assert all("user-1" not in name and "sandbox" not in name and "conversation" not in name for name in names) + assert {call.kwargs["call_id"] for call in store.create_item.await_args_list} == {"call-1", "call-2"} + assert {call.kwargs["call_id"] for call in store.set_item.await_args_list} == {"call-1", "call-2"} + + +async def test_hosted_scope_does_not_read_existing_unscoped_agent_session() -> None: + context = _platform_context() + legacy = await FoundryStateStore.get_or_create("agent_sessions", user_isolation=True) + session = AgentSession(session_id="legacy-session") + session.state["turn_count"] = 1 + async with legacy: + await legacy.create_item("response-1", session.to_dict(), call_id=context.call_id) + + scoped = AgentSessionStoreProvider().get_store(config=_config(is_hosted=True), platform_context=context) + assert await scoped.get("response-1") is None + assert await scoped.get("response-1") is None + + async with legacy: + assert await legacy.get_item("response-1", call_id=context.call_id) is not None + + +async def test_hosted_scope_does_not_read_existing_unscoped_checkpoint_or_approval() -> None: + context = _platform_context() + legacy_checkpoint = await FoundryStateStore.get_or_create("checkpoints/conversation-1", user_isolation=True) + legacy_approval = await FoundryStateStore.get_or_create("function_approvals", user_isolation=True) + async with legacy_checkpoint: + await legacy_checkpoint.create_item( + "checkpoint-1", encode_checkpoint_value(_checkpoint("checkpoint-1").to_dict()), call_id=context.call_id + ) + async with legacy_approval: + await legacy_approval.create_item( + "approval-1", _approval_request("approval-1").to_dict(), call_id=context.call_id + ) + + config = _config(is_hosted=True) + checkpoints = CheckpointStoreProvider().get_store( + config=config, context_id="conversation-1", platform_context=context + ) + approvals = FunctionApprovalStoreProvider().get_store(config=config, platform_context=context) + with pytest.raises(WorkflowCheckpointException, match="No checkpoint found"): + await checkpoints.load("checkpoint-1") + with pytest.raises(KeyError, match="does not exist"): + await approvals.load_approval_request("approval-1") + + +async def test_hosted_session_checkpoint_and_approval_data_stay_in_one_sandbox() -> None: + config = _config(is_hosted=True) + first_context = _platform_context(session_id="sandbox-1") + second_context = _platform_context(session_id="sandbox-2") + sessions = AgentSessionStoreProvider() + checkpoints = CheckpointStoreProvider() + approvals = FunctionApprovalStoreProvider() + + first_session_store = sessions.get_store(config=config, platform_context=first_context) + await first_session_store.set("response-1", AgentSession()) + second_session_store = sessions.get_store(config=config, platform_context=second_context) + assert await second_session_store.get("response-1") is None + continued_context = _platform_context(call_id="call-2", session_id="sandbox-1") + continued_session_store = sessions.get_store(config=config, platform_context=continued_context) + assert await continued_session_store.get("response-1") is not None + + first_checkpoint_store = checkpoints.get_store( + config=config, context_id="conversation-1", platform_context=first_context + ) + await first_checkpoint_store.save(_checkpoint("checkpoint-1")) + continued_checkpoint_store = checkpoints.get_store( + config=config, context_id="conversation-1", platform_context=continued_context + ) + assert await continued_checkpoint_store.load("checkpoint-1") == _checkpoint("checkpoint-1") + second_checkpoint_store = checkpoints.get_store( + config=config, context_id="conversation-1", platform_context=second_context + ) + with pytest.raises(WorkflowCheckpointException, match="No checkpoint found"): + await second_checkpoint_store.load("checkpoint-1") + + first_approval_store = approvals.get_store(config=config, platform_context=first_context) + await first_approval_store.save_approval_request("approval-1", _approval_request("approval-1")) + continued_approval_store = approvals.get_store(config=config, platform_context=continued_context) + assert await continued_approval_store.load_approval_request("approval-1") == _approval_request("approval-1") + second_approval_store = approvals.get_store(config=config, platform_context=second_context) + with pytest.raises(KeyError, match="does not exist"): + await second_approval_store.load_approval_request("approval-1") + assert await first_approval_store.load_approval_request("approval-1") == _approval_request("approval-1") + + +async def test_hosted_approval_context_separates_workflows_in_one_sandbox() -> None: + config = _config(is_hosted=True) + context = _platform_context() + approvals = FunctionApprovalStoreProvider() + first = approvals.get_store(config=config, platform_context=context, context_id="workflow-1") + second = approvals.get_store(config=config, platform_context=context, context_id="workflow-2") + + await first.save_approval_request("approval-1", _approval_request("approval-1")) + with pytest.raises(KeyError, match="does not exist"): + await second.load_approval_request("approval-1") + + +async def test_hosted_session_rejects_a_stale_etag_without_overwriting_the_winner() -> None: + config = _config(is_hosted=True) + provider = AgentSessionStoreProvider() + first = provider.get_store(config=config, platform_context=_platform_context("call-1")) + second = provider.get_store(config=config, platform_context=_platform_context("call-2")) + + assert await first.get("conversation-1") is None + await first.set("conversation-1", AgentSession()) + first_session = await first.get("conversation-1") + second_session = await second.get("conversation-1") + assert first_session is not None and second_session is not None + + first_session.state["winner"] = True + second_session.state["winner"] = False + await first.set("conversation-1", first_session) + with pytest.raises(RuntimeError, match="Another request advanced this agent session"): + await second.set("conversation-1", second_session) + + current_store = provider.get_store(config=config, platform_context=_platform_context("call-3")) + current = await current_store.get("conversation-1") + assert current is not None and current.state["winner"] is True + + +async def test_concurrent_new_hosted_sessions_conflict_on_create() -> None: + config = _config(is_hosted=True) + provider = AgentSessionStoreProvider() + first = provider.get_store(config=config, platform_context=_platform_context("call-1")) + second = provider.get_store(config=config, platform_context=_platform_context("call-2")) + assert await first.get("conversation-1") is None + assert await second.get("conversation-1") is None + + await first.set("conversation-1", AgentSession()) + with pytest.raises(RuntimeError, match="Another request advanced this agent session"): + await second.set("conversation-1", AgentSession()) + + +async def test_hosted_previous_response_load_saves_a_new_key_without_predecessor_cas() -> None: + config = _config(is_hosted=True) + provider = AgentSessionStoreProvider() + first = provider.get_store(config=config, platform_context=_platform_context("call-1")) + initial_session = AgentSession() + initial_session.state["turn_count"] = 1 + await first.set("response-1", initial_session) + + next_turn = provider.get_store(config=config, platform_context=_platform_context("call-2")) + continued_session = await next_turn.get("response-1") + assert continued_session is not None + continued_session.state["turn_count"] = 2 + await next_turn.set("response-2", continued_session) + + previous = await provider.get_store(config=config, platform_context=_platform_context()).get("response-1") + current = await provider.get_store(config=config, platform_context=_platform_context()).get("response-2") + assert previous is not None and previous.state["turn_count"] == 1 + assert current is not None and current.state["turn_count"] == 2 + + +async def test_loaded_session_without_an_etag_fails_closed() -> None: + store = _store() + store.get_item = AsyncMock(return_value=SimpleNamespace(value={}, etag="")) + sessions = AgentSessionStoreProvider().get_store( + config=_config(is_hosted=True), platform_context=_platform_context() + ) + with ( + patch( + "agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create", + new=AsyncMock(return_value=store), + ), + pytest.raises(RuntimeError, match="without an ETag"), + ): + await sessions.get("conversation-1") + store.set_item.assert_not_awaited() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/basic/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/basic/README.md index 78447f73e46..27d2e33cc69 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/basic/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/basic/README.md @@ -38,6 +38,15 @@ To have a multi-turn conversation with the agent, include the previous response curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "How are you?", "previous_response_id": "REPLACE_WITH_PREVIOUS_RESPONSE_ID"}' ``` +When deployed to Foundry, reuse the same platform-provided agent session to continue +the agent's private state. The request's `previous_response_id` selects a Responses +continuation; it does not identify the Foundry sandbox. Hosted state is isolated by +the platform's user and sandbox IDs, so putting those IDs in the JSON body cannot +recover a different sandbox's state. After upgrading from unscoped hosted state, +start a fresh conversation: older MAF session data is not read by the new default +stores. See [State store](../../../../../packages/foundry_hosting/README.md#state-store) +for the ID mapping and migration implications. + ## Deploying the Agent to Foundry To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory. From 65c0142ed05739e29de5182273d7e8dde036b39a Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 25 Sep 2026 11:51:05 +0200 Subject: [PATCH 2/4] fix(python): trust platform sandbox identity for Foundry state --- .../core/agent_framework/foundry/__init__.py | 1 + .../core/agent_framework/foundry/__init__.pyi | 2 + .../core/tests/core/test_foundry_namespace.py | 3 ++ python/packages/foundry_hosting/README.md | 16 +++++-- .../_invocations.py | 4 +- .../agent_framework_foundry_hosting/_scope.py | 21 +++++--- .../_state_store.py | 1 + .../foundry_hosting/tests/test_invocations.py | 47 +++++++++++++++--- .../foundry_hosting/tests/test_responses.py | 17 +++++-- .../foundry_hosting/tests/test_scope.py | 44 +++++++++++------ .../foundry_hosting/tests/test_state_store.py | 48 +++++++++++++------ 11 files changed, 152 insertions(+), 52 deletions(-) diff --git a/python/packages/core/agent_framework/foundry/__init__.py b/python/packages/core/agent_framework/foundry/__init__.py index 7c250f0ce27..3aa7d6c9f76 100644 --- a/python/packages/core/agent_framework/foundry/__init__.py +++ b/python/packages/core/agent_framework/foundry/__init__.py @@ -40,6 +40,7 @@ "FoundryCheckpointStore": ("agent_framework_foundry_hosting", "agent-framework-foundry-hosting"), "ContextScopedStoreProvider": ("agent_framework_foundry_hosting", "agent-framework-foundry-hosting"), "FoundryFunctionApprovalStore": ("agent_framework_foundry_hosting", "agent-framework-foundry-hosting"), + "FoundryRequestScope": ("agent_framework_foundry_hosting", "agent-framework-foundry-hosting"), "FunctionApprovalStore": ("agent_framework_foundry_hosting", "agent-framework-foundry-hosting"), "FunctionApprovalStoreProvider": ("agent_framework_foundry_hosting", "agent-framework-foundry-hosting"), "FoundryToolbox": ("agent_framework_foundry_hosting", "agent-framework-foundry-hosting"), diff --git a/python/packages/core/agent_framework/foundry/__init__.pyi b/python/packages/core/agent_framework/foundry/__init__.pyi index 45f1ea0f568..e645e2cd224 100644 --- a/python/packages/core/agent_framework/foundry/__init__.pyi +++ b/python/packages/core/agent_framework/foundry/__init__.pyi @@ -37,6 +37,7 @@ from agent_framework_foundry_hosting import ( FoundryAgentSessionStore, FoundryCheckpointStore, FoundryFunctionApprovalStore, + FoundryRequestScope, FoundryToolbox, FunctionApprovalStore, FunctionApprovalStoreProvider, @@ -75,6 +76,7 @@ __all__ = [ "FoundryLocalClient", "FoundryLocalSettings", "FoundryMemoryProvider", + "FoundryRequestScope", "FoundryToolbox", "FunctionApprovalStore", "FunctionApprovalStoreProvider", diff --git a/python/packages/core/tests/core/test_foundry_namespace.py b/python/packages/core/tests/core/test_foundry_namespace.py index 3797a358092..3b3215e1aa3 100644 --- a/python/packages/core/tests/core/test_foundry_namespace.py +++ b/python/packages/core/tests/core/test_foundry_namespace.py @@ -13,6 +13,7 @@ FoundryMemoryProvider = _foundry.FoundryMemoryProvider FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY = _foundry.FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY FoundryAgentSessionStore = _foundry_hosting.FoundryAgentSessionStore +FoundryRequestScope = _foundry_hosting.FoundryRequestScope ResponsesHostServer = _foundry_hosting.ResponsesHostServer FoundryLocalClient = _foundry_local.FoundryLocalClient @@ -22,12 +23,14 @@ def test_foundry_namespace_exposes_cloud_and_local_symbols() -> None: assert foundry.FoundryChatClient is FoundryChatClient assert foundry.FoundryMemoryProvider is FoundryMemoryProvider assert foundry.FoundryAgentSessionStore is FoundryAgentSessionStore + assert foundry.FoundryRequestScope is FoundryRequestScope assert foundry.ResponsesHostServer is ResponsesHostServer assert foundry.FoundryLocalClient is FoundryLocalClient assert "FoundryChatClient" in dir(foundry) assert "FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY" in dir(foundry) assert "FoundryLocalClient" in dir(foundry) assert "FoundryAgentSessionStore" in dir(foundry) + assert "FoundryRequestScope" in dir(foundry) assert "ResponsesHostServer" in dir(foundry) diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index 2cbd6ba2664..3c7bc4e6e4d 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -96,12 +96,18 @@ encoding of the store name. For example: ### User isolation -Hosted requests take their sandbox session ID, user ID, and call ID from the trusted -AgentServer request context. All three are required; IDs in a caller's Responses or -Invocations payload cannot stand in for missing platform identity. The exported -`FoundryRequestScope.from_context(config, platform_context)` validates this identity. +Hosted requests take their sandbox session ID from the platform-configured +`FOUNDRY_AGENT_SESSION_ID` (`AgentConfig.session_id`), and their user and call IDs +from the AgentServer request context. All three are required. AgentServer can also +resolve its request-context session ID from a caller's `agent_session_id` body field +or query parameter; hosted requests reject that value when it differs from the +platform-configured ID. The exported +`FoundryRequestScope.from_context(config, platform_context)` validates this boundary. Its `storage_key` is a bounded hash of the framed user and sandbox IDs, not a caller -conversation or response ID. +conversation or response ID. If a hosted deployment does not provide +`FOUNDRY_AGENT_SESSION_ID`, default hosted state access fails closed rather than +using the caller's ID. Platform injection of this setting has not been verified in +every hosted deployment; do not bypass this check without another verified identity. | Identifier | Purpose | | --- | --- | diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py index 052eb6aadd0..03957fbfc94 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py @@ -103,9 +103,9 @@ def _partition_key(self) -> str | tuple[str, str]: context = get_request_context() if self.config.is_hosted: - if not context.session_id or not context.user_id: + if not context.user_id: raise RuntimeError( - "The hosted environment is missing session_id or user_id in the request context. " + "The hosted environment is missing user_id in the request context. " "Please ensure that the request is coming from a valid Foundry platform service." ) scope = FoundryRequestScope.from_context(self.config, context) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_scope.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_scope.py index 62f9d205ac0..d26ec915a73 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_scope.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_scope.py @@ -28,21 +28,28 @@ def from_context( *, local_session_id: str | None = None, ) -> FoundryRequestScope: - """Resolve identity from the platform context, never from a hosted caller's request. + """Resolve hosted identity from platform configuration, not caller-controlled session IDs. Args: config: AgentServer's configuration for this host. - context: Trusted platform request context. + context: Request context with platform user/call IDs and a potentially caller-supplied session ID. local_session_id: Fallback session ID for local, non-hosted requests only. Raises: RuntimeError: If a required platform identity is missing. """ - session_id = context.session_id or (local_session_id if not config.is_hosted else None) - if not session_id: - raise RuntimeError("A Foundry agent session ID is required to handle the request.") - if config.is_hosted and (not context.user_id or not context.call_id): - raise RuntimeError("Foundry hosted requests require a trusted user ID and call ID.") + if config.is_hosted: + if not config.session_id.strip(): + raise RuntimeError("Foundry hosted requests require a platform FOUNDRY_AGENT_SESSION_ID.") + if context.session_id and context.session_id != config.session_id: + raise RuntimeError("The request agent_session_id does not match the platform session ID.") + if not context.user_id or not context.call_id: + raise RuntimeError("Foundry hosted requests require a trusted user ID and call ID.") + session_id = config.session_id + else: + session_id = context.session_id or local_session_id + if not session_id: + raise RuntimeError("A Foundry agent session ID is required to handle the request.") return cls( session_id=session_id, user_id=context.user_id, diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py index a1678dc4796..f48aede64a8 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py @@ -402,6 +402,7 @@ async def delete(self, session_id: str) -> None: store = await self._get_store() async with store: await store.delete_item(session_id, call_id=self.platform_context.call_id) + self._etags.pop(session_id, None) class AgentSessionStoreProvider(StoreProvider[SessionStore]): diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index 468ffb07a88..0fc08ac0d2c 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -551,28 +551,45 @@ def test_local_ignores_user_id(self) -> None: with _request_context(session_id="sess-1", user_id="user-1"): assert server._partition_key() == "sess-1" # pyright: ignore[reportPrivateUsage] - @pytest.mark.parametrize( - ("session_id", "user_id"), - [(None, "user-1"), ("", "user-1"), ("sess-1", None), ("sess-1", ""), (None, None)], - ) - def test_hosted_requires_both_identifiers(self, session_id: str | None, user_id: str | None) -> None: + @pytest.mark.parametrize("user_id", [None, ""]) + def test_hosted_requires_user_id(self, user_id: str | None) -> None: server = InvocationsHostServer(_make_agent(response_text="hi")) server.config.is_hosted = True + server.config.session_id = "sess-1" with ( - _request_context(call_id="call-1", session_id=session_id, user_id=user_id), - pytest.raises(RuntimeError, match="missing session_id or user_id"), + _request_context(call_id="call-1", session_id="sess-1", user_id=user_id), + pytest.raises(RuntimeError, match="missing user_id"), ): server._partition_key() # pyright: ignore[reportPrivateUsage] def test_hosted_returns_composite_key(self) -> None: server = InvocationsHostServer(_make_agent(response_text="hi")) server.config.is_hosted = True + server.config.session_id = "sess-1" with _request_context(call_id="call-1", session_id="sess-1", user_id="user-1"): assert server._partition_key() == ("sess-1", "user-1") # pyright: ignore[reportPrivateUsage] + def test_hosted_uses_platform_session_when_context_omits_it(self) -> None: + server = InvocationsHostServer(_make_agent(response_text="hi")) + server.config.is_hosted = True + server.config.session_id = "sess-1" + with _request_context(call_id="call-1", user_id="user-1"): + assert server._partition_key() == ("sess-1", "user-1") # pyright: ignore[reportPrivateUsage] + + def test_hosted_rejects_a_different_request_session(self) -> None: + server = InvocationsHostServer(_make_agent(response_text="hi")) + server.config.is_hosted = True + server.config.session_id = "sess-1" + with ( + _request_context(call_id="call-1", session_id="caller-session", user_id="user-1"), + pytest.raises(RuntimeError, match="does not match"), + ): + server._partition_key() # pyright: ignore[reportPrivateUsage] + def test_hosted_requires_platform_call_id(self) -> None: server = InvocationsHostServer(_make_agent(response_text="hi")) server.config.is_hosted = True + server.config.session_id = "sess-1" with ( _request_context(session_id="sess-1", user_id="user-1"), pytest.raises(RuntimeError, match="trusted user ID and call ID"), @@ -588,6 +605,7 @@ async def test_hosted_keys_and_session_ids_preserve_identifier_values(self) -> N request = _make_request({"message": "Hi"}) for session_id, user_id in product(identifiers, repeat=2): + server.config.session_id = session_id with _request_context(call_id="call-1", session_id=session_id, user_id=user_id): key = server._partition_key() # pyright: ignore[reportPrivateUsage] response = await server._handle_invoke(request) # pyright: ignore[reportPrivateUsage] @@ -715,6 +733,8 @@ async def test_reusing_session_restores_identifier(self, hosted: bool, stream: b agent = _make_agent(response_text="ok", stream_texts=["ok"]) server = InvocationsHostServer(agent) server.config.is_hosted = hosted + if hosted: + server.config.session_id = "sess-1" request = _make_request({"message": "Hi", "stream": stream}) expected_id = '["sess-1","user-1"]' if hosted else "sess-1" @@ -762,11 +782,22 @@ async def test_hosted_missing_call_id_rejects_before_running_agent(self) -> None agent = _make_agent(response_text="hi") server = InvocationsHostServer(agent) server.config.is_hosted = True + server.config.session_id = "sess-1" with _request_context(session_id="sess-1", user_id="user-1"): response = await server._handle_invoke(_make_request({"message": "Hi"})) # pyright: ignore[reportPrivateUsage] assert response.status_code == 500 assert agent.calls == [] + async def test_hosted_different_caller_session_rejects_before_running_agent(self) -> None: + agent = _make_agent(response_text="hi") + server = InvocationsHostServer(agent) + server.config.is_hosted = True + server.config.session_id = "sess-1" + with _request_context(call_id="call-1", session_id="caller-session", user_id="user-1"): + response = await server._handle_invoke(_make_request({"message": "Hi"})) # pyright: ignore[reportPrivateUsage] + assert response.status_code == 500 + assert agent.calls == [] + async def test_non_streaming_returns_agent_text(self) -> None: agent = _make_agent(response_text="Hello!") server = InvocationsHostServer(agent) @@ -841,6 +872,7 @@ def update_session(session: AgentSession) -> None: sessions: list[AgentSession] = [] for session_id, user_id in identifiers: + server.config.session_id = session_id with _request_context(call_id="call-1", session_id=session_id, user_id=user_id): response = await server._handle_invoke( # pyright: ignore[reportPrivateUsage] _make_request({"message": "Hi", "stream": stream}) @@ -860,6 +892,7 @@ def update_session(session: AgentSession) -> None: assert sessions[0].session_id != sessions[1].session_id for (session_id, user_id), session in zip(identifiers, sessions): + server.config.session_id = session_id with _request_context(call_id="call-2", session_id=session_id, user_id=user_id): response = await server._handle_invoke( # pyright: ignore[reportPrivateUsage] _make_request({"message": "Continue", "stream": stream}) diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index ab2cfb359db..c80e8dc24db 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -1086,16 +1086,27 @@ async def test_previous_response_requires_existing_agent_session(self) -> None: class TestAgentSessionPersistence: - async def test_hosted_missing_call_id_fails_without_running_agent(self) -> None: + @pytest.mark.parametrize( + ("platform_session_id", "request_session_id", "call_id", "expected_error"), + [ + ("sandbox-1", "sandbox-1", None, "trusted user ID and call ID"), + ("", "caller-session", "call-1", "FOUNDRY_AGENT_SESSION_ID"), + ("sandbox-1", "caller-session", "call-1", "does not match"), + ], + ) + async def test_hosted_invalid_identity_fails_without_running_agent( + self, platform_session_id: str, request_session_id: str, call_id: str | None, expected_error: str + ) -> None: agent = _make_agent() server = _make_server(agent, session_store=SessionStore()) server.config.is_hosted = True + server.config.session_id = platform_session_id request = CreateResponse(model="m", input="hi") context = ResponseContext(response_id="response-1", mode_flags=MagicMock()) with patch( "agent_framework_foundry_hosting._responses.get_request_context", - return_value=FoundryAgentRequestContext(session_id="sandbox-1", user_id="user-1"), + return_value=FoundryAgentRequestContext(session_id=request_session_id, user_id="user-1", call_id=call_id), ): events = [ event @@ -1114,7 +1125,7 @@ async def test_hosted_missing_call_id_fails_without_running_agent(self) -> None: failed_event = cast(Mapping[str, Any], failed_events[0]) response = cast(Mapping[str, Any], failed_event["response"]) error = cast(Mapping[str, Any], response["error"]) - assert "trusted user ID and call ID" in error["message"] + assert expected_error in error["message"] agent.run.assert_not_called() agent.create_session.assert_not_called() diff --git a/python/packages/foundry_hosting/tests/test_scope.py b/python/packages/foundry_hosting/tests/test_scope.py index 84e41dd7cd5..5582629fe0a 100644 --- a/python/packages/foundry_hosting/tests/test_scope.py +++ b/python/packages/foundry_hosting/tests/test_scope.py @@ -12,29 +12,46 @@ from agent_framework_foundry_hosting import FoundryRequestScope -def _config(*, is_hosted: bool) -> AgentConfig: +def _config(*, is_hosted: bool, session_id: str = "sandbox") -> AgentConfig: config = MagicMock(spec=AgentConfig) config.is_hosted = is_hosted + config.session_id = session_id return config @pytest.mark.parametrize( - ("session_id", "user_id", "call_id", "error"), + ("platform_session_id", "request_session_id", "user_id", "call_id", "error"), [ - (None, "user", "call", "session ID"), - ("", "user", "call", "session ID"), - ("sandbox", None, "call", "user ID and call ID"), - ("sandbox", "", "call", "user ID and call ID"), - ("sandbox", "user", None, "user ID and call ID"), - ("sandbox", "user", "", "user ID and call ID"), + ("", "sandbox", "user", "call", "FOUNDRY_AGENT_SESSION_ID"), + (" ", "sandbox", "user", "call", "FOUNDRY_AGENT_SESSION_ID"), + ("sandbox", "other-sandbox", "user", "call", "does not match"), + ("sandbox", "sandbox", None, "call", "user ID and call ID"), + ("sandbox", "sandbox", "", "call", "user ID and call ID"), + ("sandbox", "sandbox", "user", None, "user ID and call ID"), + ("sandbox", "sandbox", "user", "", "user ID and call ID"), ], ) def test_hosted_scope_requires_platform_identity( - session_id: str | None, user_id: str | None, call_id: str | None, error: str + platform_session_id: str, + request_session_id: str, + user_id: str | None, + call_id: str | None, + error: str, ) -> None: - context = FoundryAgentRequestContext(session_id=session_id, user_id=user_id, call_id=call_id) + context = FoundryAgentRequestContext(session_id=request_session_id, user_id=user_id, call_id=call_id) with pytest.raises(RuntimeError, match=error): - FoundryRequestScope.from_context(_config(is_hosted=True), context, local_session_id="caller-supplied") + FoundryRequestScope.from_context( + _config(is_hosted=True, session_id=platform_session_id), context, local_session_id="caller-supplied" + ) + + +def test_hosted_scope_uses_platform_session_when_request_omits_it() -> None: + scope = FoundryRequestScope.from_context( + _config(is_hosted=True), + FoundryAgentRequestContext(user_id="user", call_id="call"), + ) + assert scope.session_id == "sandbox" + assert scope.is_hosted is True def test_local_scope_can_use_a_local_session_without_hosted_identity() -> None: @@ -49,11 +66,10 @@ def test_local_scope_can_use_a_local_session_without_hosted_identity() -> None: def test_store_key_frames_user_and_sandbox_but_not_call_id() -> None: - config = _config(is_hosted=True) - def scope(user: str, sandbox: str, call: str) -> FoundryRequestScope: return FoundryRequestScope.from_context( - config, FoundryAgentRequestContext(user_id=user, session_id=sandbox, call_id=call) + _config(is_hosted=True, session_id=sandbox), + FoundryAgentRequestContext(user_id=user, session_id=sandbox, call_id=call), ) first = scope("user/a", "b", "call-1") diff --git a/python/packages/foundry_hosting/tests/test_state_store.py b/python/packages/foundry_hosting/tests/test_state_store.py index 1aff9e7a6be..c8232f9ef47 100644 --- a/python/packages/foundry_hosting/tests/test_state_store.py +++ b/python/packages/foundry_hosting/tests/test_state_store.py @@ -54,7 +54,7 @@ def _store() -> MagicMock: return store -def _config(*, is_hosted: bool) -> AgentConfig: +def _config(*, is_hosted: bool, session_id: str | None = None) -> AgentConfig: return AgentConfig( agent_name="", agent_version="", @@ -62,7 +62,7 @@ def _config(*, is_hosted: bool) -> AgentConfig: is_hosted=is_hosted, project_endpoint="", project_id="", - session_id="", + session_id=session_id if session_id is not None else ("sandbox-1" if is_hosted else ""), port=8088, appinsights_connection_string="", otlp_endpoint="", @@ -518,6 +518,24 @@ async def test_delete_agent_session_is_idempotent() -> None: store.delete_item.assert_awaited_once_with("storage-session-1", call_id="call-1") +async def test_deleted_hosted_agent_session_can_be_recreated_with_the_same_store() -> None: + config = _config(is_hosted=True) + context = _platform_context() + sessions = AgentSessionStoreProvider().get_store(config=config, platform_context=context) + first = AgentSession() + first.state["turn"] = 1 + await sessions.set("conversation-1", first) + assert await sessions.get("conversation-1") is not None + await sessions.delete("conversation-1") + + replacement = AgentSession() + replacement.state["turn"] = 2 + await sessions.set("conversation-1", replacement) + + current = await AgentSessionStoreProvider().get_store(config=config, platform_context=context).get("conversation-1") + assert current is not None and current.state["turn"] == 2 + + @pytest.mark.parametrize("is_hosted", [True, False]) def test_agent_session_storage_provider_uses_foundry_store(is_hosted: bool) -> None: provider = AgentSessionStoreProvider() @@ -545,19 +563,20 @@ def test_agent_session_storage_provider_creates_request_scoped_storage() -> None @pytest.mark.parametrize("provider", ["agent", "checkpoint", "approval"]) @pytest.mark.parametrize( - ("session_id", "user_id", "call_id"), + ("platform_session_id", "request_session_id", "user_id", "call_id"), [ - (None, "user", "call"), - ("sandbox", None, "call"), - ("sandbox", "user", None), + ("", "sandbox-1", "user", "call"), + ("sandbox-1", "other-sandbox", "user", "call"), + ("sandbox-1", "sandbox-1", None, "call"), + ("sandbox-1", "sandbox-1", "user", None), ], ) def test_hosted_store_providers_reject_missing_platform_identity( - provider: str, session_id: str | None, user_id: str | None, call_id: str | None + provider: str, platform_session_id: str, request_session_id: str, user_id: str | None, call_id: str | None ) -> None: - context = FoundryAgentRequestContext(session_id=session_id, user_id=user_id, call_id=call_id) - config = _config(is_hosted=True) - with pytest.raises(RuntimeError, match="session ID|user ID and call ID"): + context = FoundryAgentRequestContext(session_id=request_session_id, user_id=user_id, call_id=call_id) + config = _config(is_hosted=True, session_id=platform_session_id) + with pytest.raises(RuntimeError, match="FOUNDRY_AGENT_SESSION_ID|does not match|user ID and call ID"): if provider == "agent": AgentSessionStoreProvider().get_store(config=config, platform_context=context) elif provider == "checkpoint": @@ -575,7 +594,6 @@ async def get_store(name: str, *, user_isolation: bool) -> MagicMock: names.append(name) return store - config = _config(is_hosted=True) contexts = [ _platform_context(call_id="call-1", session_id="../sandbox/" * 100), _platform_context(call_id="call-2", session_id="other-sandbox"), @@ -583,6 +601,7 @@ async def get_store(name: str, *, user_isolation: bool) -> MagicMock: with patch("agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create", new=get_store): for context in contexts: + config = _config(is_hosted=True, session_id=context.session_id) sessions = AgentSessionStoreProvider().get_store(config=config, platform_context=context) await sessions.set("response-1", AgentSession()) checkpoints = CheckpointStoreProvider().get_store( @@ -645,13 +664,14 @@ async def test_hosted_session_checkpoint_and_approval_data_stay_in_one_sandbox() config = _config(is_hosted=True) first_context = _platform_context(session_id="sandbox-1") second_context = _platform_context(session_id="sandbox-2") + second_config = _config(is_hosted=True, session_id="sandbox-2") sessions = AgentSessionStoreProvider() checkpoints = CheckpointStoreProvider() approvals = FunctionApprovalStoreProvider() first_session_store = sessions.get_store(config=config, platform_context=first_context) await first_session_store.set("response-1", AgentSession()) - second_session_store = sessions.get_store(config=config, platform_context=second_context) + second_session_store = sessions.get_store(config=second_config, platform_context=second_context) assert await second_session_store.get("response-1") is None continued_context = _platform_context(call_id="call-2", session_id="sandbox-1") continued_session_store = sessions.get_store(config=config, platform_context=continued_context) @@ -666,7 +686,7 @@ async def test_hosted_session_checkpoint_and_approval_data_stay_in_one_sandbox() ) assert await continued_checkpoint_store.load("checkpoint-1") == _checkpoint("checkpoint-1") second_checkpoint_store = checkpoints.get_store( - config=config, context_id="conversation-1", platform_context=second_context + config=second_config, context_id="conversation-1", platform_context=second_context ) with pytest.raises(WorkflowCheckpointException, match="No checkpoint found"): await second_checkpoint_store.load("checkpoint-1") @@ -675,7 +695,7 @@ async def test_hosted_session_checkpoint_and_approval_data_stay_in_one_sandbox() await first_approval_store.save_approval_request("approval-1", _approval_request("approval-1")) continued_approval_store = approvals.get_store(config=config, platform_context=continued_context) assert await continued_approval_store.load_approval_request("approval-1") == _approval_request("approval-1") - second_approval_store = approvals.get_store(config=config, platform_context=second_context) + second_approval_store = approvals.get_store(config=second_config, platform_context=second_context) with pytest.raises(KeyError, match="does not exist"): await second_approval_store.load_approval_request("approval-1") assert await first_approval_store.load_approval_request("approval-1") == _approval_request("approval-1") From fefcaebd268a4230f4df32b70def48a17fea4c43 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 28 Sep 2026 10:27:27 +0200 Subject: [PATCH 3/4] refactor(python): remove unused Foundry approval context option --- .../_state_store.py | 6 +----- .../foundry_hosting/tests/test_state_store.py | 16 +--------------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py index f48aede64a8..102b05a1b94 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py @@ -278,15 +278,13 @@ def __init__( platform_context: FoundryAgentRequestContext, *, scope: FoundryRequestScope | None = None, - context_id: str | None = None, ) -> None: self.platform_context = platform_context self._scope = scope - self._context_id = context_id async def _get_store(self) -> FoundryStateStore: return await FoundryStateStore.get_or_create( - _store_name(self.DEFAULT_ROOT_SCOPE, self._scope, self._context_id), + _store_name(self.DEFAULT_ROOT_SCOPE, self._scope), user_isolation=True, ) @@ -318,13 +316,11 @@ def get_store( *, config: AgentConfig, platform_context: FoundryAgentRequestContext, - context_id: str | None = None, ) -> FunctionApprovalStore: """Get function approval store for the requested hosting environment.""" return FoundryFunctionApprovalStore( platform_context, scope=_store_scope(config, platform_context), - context_id=context_id, ) diff --git a/python/packages/foundry_hosting/tests/test_state_store.py b/python/packages/foundry_hosting/tests/test_state_store.py index c8232f9ef47..65ca09acd6b 100644 --- a/python/packages/foundry_hosting/tests/test_state_store.py +++ b/python/packages/foundry_hosting/tests/test_state_store.py @@ -608,9 +608,7 @@ async def get_store(name: str, *, user_isolation: bool) -> MagicMock: config=config, context_id="../../conversation", platform_context=context ) await checkpoints.save(_checkpoint("checkpoint-1")) - approvals = FunctionApprovalStoreProvider().get_store( - config=config, platform_context=context, context_id="workflow" - ) + approvals = FunctionApprovalStoreProvider().get_store(config=config, platform_context=context) await approvals.save_approval_request("approval-1", _approval_request("approval-1")) assert len(names) == len(set(names)) == 6 @@ -701,18 +699,6 @@ async def test_hosted_session_checkpoint_and_approval_data_stay_in_one_sandbox() assert await first_approval_store.load_approval_request("approval-1") == _approval_request("approval-1") -async def test_hosted_approval_context_separates_workflows_in_one_sandbox() -> None: - config = _config(is_hosted=True) - context = _platform_context() - approvals = FunctionApprovalStoreProvider() - first = approvals.get_store(config=config, platform_context=context, context_id="workflow-1") - second = approvals.get_store(config=config, platform_context=context, context_id="workflow-2") - - await first.save_approval_request("approval-1", _approval_request("approval-1")) - with pytest.raises(KeyError, match="does not exist"): - await second.load_approval_request("approval-1") - - async def test_hosted_session_rejects_a_stale_etag_without_overwriting_the_winner() -> None: config = _config(is_hosted=True) provider = AgentSessionStoreProvider() From 1f255cbf731335802e26b98194e0a66a2677d55d Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 28 Sep 2026 11:08:18 +0200 Subject: [PATCH 4/4] fix(python): scope persisted Invocations to routed sandbox --- python/packages/foundry_hosting/README.md | 56 ++-- .../_invocations.py | 64 ++++- .../_state_store.py | 11 +- .../foundry_hosting/tests/test_invocations.py | 263 +++++++++++++++++- .../foundry_hosting/tests/test_state_store.py | 31 +++ .../invocations/basic/README.md | 34 ++- 6 files changed, 418 insertions(+), 41 deletions(-) diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index 3c7bc4e6e4d..f1094969032 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -96,18 +96,26 @@ encoding of the store name. For example: ### User isolation -Hosted requests take their sandbox session ID from the platform-configured -`FOUNDRY_AGENT_SESSION_ID` (`AgentConfig.session_id`), and their user and call IDs -from the AgentServer request context. All three are required. AgentServer can also -resolve its request-context session ID from a caller's `agent_session_id` body field -or query parameter; hosted requests reject that value when it differs from the -platform-configured ID. The exported -`FoundryRequestScope.from_context(config, platform_context)` validates this boundary. -Its `storage_key` is a bounded hash of the framed user and sandbox IDs, not a caller -conversation or response ID. If a hosted deployment does not provide -`FOUNDRY_AGENT_SESSION_ID`, default hosted state access fails closed rather than -using the caller's ID. Platform injection of this setting has not been verified in -every hosted deployment; do not bypass this check without another verified identity. +Hosted requests require platform user and call IDs from the AgentServer request +context. Responses also requires the platform-configured `FOUNDRY_AGENT_SESSION_ID` +(`AgentConfig.session_id`) for sandbox identity; a different caller-supplied +`agent_session_id` fails closed. For Invocations, the +[platform routes by the `agent_session_id` query parameter](https://learn.microsoft.com/azure/foundry/agents/how-to/manage-hosted-sessions#how-each-protocol-binds-an-invocation-to-a-session). +When `FOUNDRY_AGENT_SESSION_ID` is present, it must match that query and the +resolved request context. When it is absent, only an explicit, nonempty routed +query matching the request context can identify the sandbox; duplicate query +IDs or a request without the query are rejected rather than using the SDK's +generated fallback ID. +This means an automatically created first Invocations session without a query +cannot access default hosted state when the platform does not configure the +session ID. The platform documentation does not guarantee that the environment +variable is provided for every sandbox. + +`FoundryRequestScope.from_context(config, platform_context)` requires the +configured ID and remains strict for Responses and direct store providers. Only +the Invocations host accepts the verified routed-query alternative. Its +`storage_key` is a bounded hash of the framed user and sandbox IDs, not a caller +conversation or response ID. | Identifier | Purpose | | --- | --- | @@ -127,13 +135,15 @@ behavior. Applications that supply custom store providers must implement equival hosted user and sandbox isolation. **Existing hosted state is not migrated.** The default stores never fall back to -legacy unscoped `agent_sessions`, `checkpoints/`, or -`function_approvals` data: an old MAF session, workflow checkpoint, or pending approval -cannot be resumed through the new default hosted stores. Start a fresh Responses -conversation rather than reusing an old `previous_response_id` or conversation ID; -the separate AgentServer response store is not migrated by this change. Recovering -old state requires a separately designed migration that verifies the original user's -and sandbox's ownership; reading unscoped data by user alone is not safe. +legacy unscoped `agent_sessions`, `invocation_sessions`, +`checkpoints/`, or `function_approvals` data: an old MAF session, +workflow checkpoint, or pending approval cannot be resumed through the new +default hosted stores. Start a fresh Responses conversation rather than reusing +an old `previous_response_id` or conversation ID; Invocations starts with an +empty MAF session in its scoped store. The separate AgentServer response store +is not migrated by this change. Recovering old state requires a separately +designed migration that verifies the original user's and sandbox's ownership; +reading unscoped data by user alone is not safe. ### Agent Sessions @@ -170,9 +180,11 @@ their last write; an invocation after expiry starts a fresh session. Existing stores retain their creation-time settings, and custom providers own their retention policies. -Applications must coordinate overlapping requests for the same session; the store does not -provide transactions or exactly-once execution. Independent local applications should use -separate state roots or store providers. +Default hosted stores reject stale ETag and duplicate-create writes for +overlapping turns instead of overwriting a newer MAF session. This does not +provide transactions or exactly-once execution for agent/tool side effects; +applications still need to coordinate overlapping requests. Independent local +applications should use separate state roots or store providers. ### Workflow checkpoints diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py index 03957fbfc94..417a175eb3b 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py @@ -86,7 +86,12 @@ def __init__( self.invoke_handler(self._handle_invoke) mark_feature_used(FeatureIndex.FOUNDRY_HOSTING) - def _partition_key(self) -> str | tuple[str, str]: + def _partition_key( + self, + *, + context: FoundryAgentRequestContext | None = None, + scope: FoundryRequestScope | None = None, + ) -> str | tuple[str, str]: """Get the partition key for the current request. A hosted partition key is a tuple containing the session ID and user ID, @@ -100,7 +105,8 @@ def _partition_key(self) -> str | tuple[str, str]: Exceptions: RuntimeError: If the context doesn't contain the expected IDs. """ - context = get_request_context() + if context is None: + context = get_request_context() if self.config.is_hosted: if not context.user_id: @@ -108,8 +114,8 @@ def _partition_key(self) -> str | tuple[str, str]: "The hosted environment is missing user_id in the request context. " "Please ensure that the request is coming from a valid Foundry platform service." ) - scope = FoundryRequestScope.from_context(self.config, context) - return scope.session_id, context.user_id + hosted_scope = scope or FoundryRequestScope.from_context(self.config, context) + return hosted_scope.session_id, context.user_id if not context.session_id: raise RuntimeError( @@ -118,6 +124,34 @@ def _partition_key(self) -> str | tuple[str, str]: return context.session_id + def _hosted_scope(self, request: Request, context: FoundryAgentRequestContext) -> FoundryRequestScope: + """Accept an unconfigured session only when the routed Invocations query identifies it.""" + if not context.user_id: + raise RuntimeError("The hosted environment is missing user_id in the request context.") + + routed_session_ids = request.query_params.getlist("agent_session_id") + if len(routed_session_ids) > 1: + raise RuntimeError("Hosted Invocations requires exactly one agent_session_id query parameter.") + routed_session_id = routed_session_ids[0] if routed_session_ids else None + if self.config.session_id: + if routed_session_id is not None and routed_session_id != self.config.session_id: + raise RuntimeError("The request agent_session_id does not match the platform session ID.") + return FoundryRequestScope.from_context(self.config, context) + + if not routed_session_id or routed_session_id != context.session_id: + raise RuntimeError( + "Hosted Invocations without FOUNDRY_AGENT_SESSION_ID require an explicit routed " + "agent_session_id query parameter matching the request context." + ) + if not context.call_id: + raise RuntimeError("Foundry hosted requests require a trusted user ID and call ID.") + return FoundryRequestScope( + session_id=routed_session_id, + user_id=context.user_id, + call_id=context.call_id, + is_hosted=True, + ) + @asynccontextmanager async def _request_agent(self) -> AsyncGenerator[SupportsAgentRun]: agent = await resolve_agent(self._agent) @@ -128,13 +162,21 @@ async def _request_agent(self) -> AsyncGenerator[SupportsAgentRun]: @asynccontextmanager async def _request_session( - self, partition_key: str | tuple[str, str], context: FoundryAgentRequestContext + self, + partition_key: str | tuple[str, str], + context: FoundryAgentRequestContext, + *, + hosted_scope: FoundryRequestScope | None = None, ) -> AsyncGenerator[AgentSession]: session_id = ( json.dumps(partition_key, separators=(",", ":")) if isinstance(partition_key, tuple) else partition_key ) try: - store = self._session_store_provider.get_store(config=self.config, platform_context=context) + provider = self._session_store_provider + if hosted_scope is not None and not self.config.session_id and type(provider) is AgentSessionStoreProvider: + store = provider._get_scoped_store(context, hosted_scope) # pyright: ignore[reportPrivateUsage] + else: + store = provider.get_store(config=self.config, platform_context=context) session = await store.get(session_id) if session is None: session = AgentSession(session_id=session_id) @@ -159,8 +201,10 @@ async def _request_session( async def _handle_invoke(self, request: Request) -> Response: """Invoke the agent with the given request.""" + context = get_request_context() try: - partition_key = self._partition_key() + hosted_scope = self._hosted_scope(request, context) if self.config.is_hosted else None + partition_key = self._partition_key(context=context, scope=hosted_scope) except Exception as e: return Response(content=str(e), status_code=500) @@ -174,13 +218,11 @@ async def _handle_invoke(self, request: Request) -> Response: return StreamingResponse(content=error, status_code=400) return Response(content=error, status_code=400) - context = get_request_context() - if stream: async def stream_response() -> AsyncGenerator[str]: async with ( - self._request_session(partition_key, context) as session, + self._request_session(partition_key, context, hosted_scope=hosted_scope) as session, self._request_agent() as agent, ): stream = agent.run(user_message, session=session, stream=True) @@ -199,7 +241,7 @@ async def stream_response() -> AsyncGenerator[str]: return _InvocationStreamingResponse(stream_response()) async with ( - self._request_session(partition_key, context) as session, + self._request_session(partition_key, context, hosted_scope=hosted_scope) as session, self._request_agent() as agent, ): response = await agent.run([user_message], session=session) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py index 102b05a1b94..b4645b3de63 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py @@ -417,13 +417,18 @@ def __init__(self, *, store_name: str | None = None) -> None: raise ValueError("store_name must be a non-empty string") self._store_name = store_name - def get_store(self, *, config: AgentConfig, platform_context: FoundryAgentRequestContext) -> SessionStore: - """Get agent session store for the requested hosting environment.""" + def _get_scoped_store( + self, platform_context: FoundryAgentRequestContext, scope: FoundryRequestScope | None + ) -> SessionStore: return FoundryAgentSessionStore( platform_context, store_name=self._store_name, - scope=_store_scope(config, platform_context), + scope=scope, ) + def get_store(self, *, config: AgentConfig, platform_context: FoundryAgentRequestContext) -> SessionStore: + """Get agent session store for the requested hosting environment.""" + return self._get_scoped_store(platform_context, _store_scope(config, platform_context)) + # endregion Agent session persistence diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index 0fc08ac0d2c..6e7dd818f68 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -20,6 +20,7 @@ from typing import cast from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest from agent_framework import ( Agent, @@ -42,9 +43,12 @@ from azure.ai.agentserver.core import ( AgentConfig, FoundryAgentRequestContext, + get_request_context, reset_request_context, set_request_context, ) +from azure.ai.agentserver.core.storage import FoundryStateStore +from starlette.datastructures import QueryParams from starlette.requests import ClientDisconnect, Request from starlette.responses import Response, StreamingResponse from typing_extensions import Any @@ -190,10 +194,13 @@ def _make_agent( return _FakeAgent(response=response, stream_updates=stream_updates, update_session=update_session) -def _make_request(payload: dict[str, Any]) -> Request: +def _make_request(payload: dict[str, Any], *, agent_session_id: str | None = None) -> Request: """Build a mock Starlette request whose ``json()`` returns ``payload``.""" request = MagicMock(spec=Request) request.json = AsyncMock(return_value=payload) + request.query_params = ( + QueryParams({"agent_session_id": agent_session_id}) if agent_session_id is not None else QueryParams() + ) return request @@ -257,6 +264,46 @@ def update_session(session: AgentSession) -> None: assert restored is not None assert restored.state == {"protocol": "responses"} + @pytest.mark.parametrize("stream", [False, True]) + async def test_default_hosted_invocations_isolate_sandboxes_and_users(self, stream: bool) -> None: + store_names: list[str] = [] + real_get_or_create = FoundryStateStore.get_or_create + + async def capture_store_name(name: str, *, user_isolation: bool) -> FoundryStateStore: + assert user_isolation is True + store_names.append(name) + return await real_get_or_create(name, user_isolation=user_isolation) + + def update_session(session: AgentSession) -> None: + session.state["turn"] = session.state.get("turn", 0) + 1 + + agent = _make_agent(response_text="ok", stream_texts=["ok"], update_session=update_session) + identities = [("sandbox-a", "user-1"), ("sandbox-b", "user-1"), ("sandbox-a", "user-2")] + + with patch( + "agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create", + new=capture_store_name, + ): + for round_number in (1, 2): + for sandbox_id, user_id in identities: + server = InvocationsHostServer(agent) + server.config.is_hosted = True + server.config.session_id = "" + with _request_context(call_id=f"call-{round_number}", user_id=user_id, session_id=sandbox_id): + response = await server._handle_invoke( # pyright: ignore[reportPrivateUsage] + _make_request({"message": "next", "stream": stream}, agent_session_id=sandbox_id) + ) + if isinstance(response, StreamingResponse): + assert await _collect_stream(response) == "ok" + else: + assert bytes(response.body).decode() == "ok" + assert agent.calls[-1]["session"].state["turn"] == round_number + + assert len(store_names) == 12 + assert len(set(store_names)) == len(identities) + assert all(name.startswith("invocation_sessions/v2/") and len(name) <= 128 for name in store_names) + assert all("sandbox" not in name and "user" not in name for name in store_names) + @pytest.mark.parametrize("spec_version", ["2.0", "2.4"]) @pytest.mark.parametrize("keep_alive", [False, True]) async def test_disconnect_closes_stream_before_session_save(self, spec_version: str, keep_alive: bool) -> None: @@ -629,6 +676,220 @@ async def test_hosted_keys_and_session_ids_preserve_identifier_values(self) -> N class TestHandleInvoke: + async def test_sdk_routes_explicit_query_but_not_generated_session_without_env(self) -> None: + agent = _make_agent(response_text="ok") + server = InvocationsHostServer(agent) + server.config.is_hosted = True + server.config.session_id = "" + headers = {"x-agent-user-id": "user-1", "x-agent-foundry-call-id": "call-1"} + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=server), base_url="http://test") as client: + routed = await client.post( + "/invocations", + params={"agent_session_id": "sandbox-1"}, + json={"message": "hello"}, + headers=headers, + ) + generated = await client.post("/invocations", json={"message": "hello"}, headers=headers) + ambiguous = await client.post( + "/invocations", + params=[("agent_session_id", "sandbox-1"), ("agent_session_id", "sandbox-2")], + json={"message": "hello"}, + headers=headers, + ) + + assert routed.status_code == 200 + assert routed.headers["x-agent-session-id"] == "sandbox-1" + assert agent.calls[0]["session"].session_id == '["sandbox-1","user-1"]' + assert generated.status_code == 500 + assert "explicit routed agent_session_id query parameter" in generated.text + assert ambiguous.status_code == 500 + assert "exactly one agent_session_id" in ambiguous.text + assert len(agent.calls) == 1 + + async def test_sdk_local_request_without_session_query_keeps_single_user_fallback(self) -> None: + agent = _make_agent(response_text="ok") + server = InvocationsHostServer(agent) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=server), base_url="http://test") as client: + result = await client.post("/invocations", json={"message": "hello"}) + assert result.status_code == 200 + assert agent.calls[0]["session"].session_id == result.headers["x-agent-session-id"] + + @pytest.mark.parametrize("stream", [False, True]) + async def test_hosted_routed_query_works_without_configured_session(self, stream: bool) -> None: + agent = _make_agent(response_text="ok", stream_texts=["ok"]) + server = InvocationsHostServer(agent) + server.config.is_hosted = True + server.config.session_id = "" + request = _make_request({"message": "hello", "stream": stream}, agent_session_id="sandbox-1") + + with _request_context(call_id="call-1", user_id="user-1", session_id="sandbox-1"): + response = await server._handle_invoke(request) # pyright: ignore[reportPrivateUsage] + if isinstance(response, StreamingResponse): + assert await _collect_stream(response) == "ok" + else: + assert bytes(response.body).decode() == "ok" + + assert agent.calls[0]["session"].session_id == '["sandbox-1","user-1"]' + + @pytest.mark.parametrize("stream", [False, True]) + async def test_hosted_routed_stream_captures_request_context(self, stream: bool) -> None: + store = _mock_session_store() + provider = _SessionStoreProvider(store) + server = InvocationsHostServer( + _make_agent(response_text="ok", stream_texts=["ok"]), agent_session_store_provider=provider + ) + server.config.is_hosted = True + server.config.session_id = "" + request = _make_request({"message": "hello", "stream": stream}, agent_session_id="sandbox-1") + + with _request_context(call_id="call-1", user_id="user-1", session_id="sandbox-1"): + context = get_request_context() + response = await server._handle_invoke(request) # pyright: ignore[reportPrivateUsage] + if isinstance(response, StreamingResponse): + assert provider.contexts == [] + assert await _collect_stream(response) == "ok" + else: + assert bytes(response.body).decode() == "ok" + + assert provider.contexts == [context] + store.set.assert_awaited_once() + + @pytest.mark.parametrize( + ("configured_id", "context_id", "query_id", "body", "call_id", "error"), + [ + ("", "generated-id", None, {}, "call-1", "explicit routed agent_session_id query parameter"), + ("", "generated-id", None, {"agent_session_id": "sandbox-1"}, "call-1", "explicit routed agent_session_id"), + ("", "sandbox-1", "", {}, "call-1", "explicit routed agent_session_id"), + ("", "sandbox-1", "other-sandbox", {}, "call-1", "matching the request context"), + ("", "sandbox-1", "sandbox-1", {}, None, "trusted user ID and call ID"), + ("sandbox-1", "sandbox-1", "other-sandbox", {}, "call-1", "does not match"), + ], + ) + async def test_hosted_invocation_rejects_unverified_route_before_storage( + self, + configured_id: str, + context_id: str, + query_id: str | None, + body: dict[str, Any], + call_id: str | None, + error: str, + ) -> None: + agent = _make_agent(response_text="ok") + store = _mock_session_store() + provider = _SessionStoreProvider(store) + server = InvocationsHostServer(agent, agent_session_store_provider=provider) + server.config.is_hosted = True + server.config.session_id = configured_id + with _request_context(call_id=call_id, user_id="user-1", session_id=context_id): + response = await server._handle_invoke( # pyright: ignore[reportPrivateUsage] + _make_request({"message": "hello", **body}, agent_session_id=query_id) + ) + assert response.status_code == 500 + assert error in bytes(response.body).decode() + assert agent.calls == [] + assert provider.contexts == [] + + async def test_hosted_invocation_matching_config_and_query_succeeds(self) -> None: + agent = _make_agent(response_text="ok") + server = InvocationsHostServer(agent) + server.config.is_hosted = True + server.config.session_id = "sandbox-1" + with _request_context(call_id="call-1", user_id="user-1", session_id="sandbox-1"): + response = await server._handle_invoke( # pyright: ignore[reportPrivateUsage] + _make_request({"message": "hello"}, agent_session_id="sandbox-1") + ) + assert response.status_code == 200 + assert agent.calls[0]["session"].session_id == '["sandbox-1","user-1"]' + + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("initially_stored", [False, True]) + async def test_overlapping_hosted_invocations_do_not_overwrite_state( + self, stream: bool, initially_stored: bool + ) -> None: + sandbox_id = "concurrent-sandbox" + user_id = "user-1" + if initially_stored: + initial = InvocationsHostServer(_make_agent(response_text="ok")) + initial.config.is_hosted = True + initial.config.session_id = "" + with _request_context(call_id="call-0", user_id=user_id, session_id=sandbox_id): + await initial._handle_invoke( # pyright: ignore[reportPrivateUsage] + _make_request({"message": "seed"}, agent_session_id=sandbox_id) + ) + + started = [asyncio.Event(), asyncio.Event()] + released = [asyncio.Event(), asyncio.Event()] + + class PausingAgent(_FakeAgent): + def __init__(self, index: int) -> None: + super().__init__() + self.index = index + + def run( + self, + messages: Any = None, + *, + stream: bool = False, + session: AgentSession | None = None, + **kwargs: Any, + ) -> Any: + assert session is not None + + async def run_once() -> AgentResponse: + started[self.index].set() + await released[self.index].wait() + session.state["winner"] = self.index + return AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])]) + + async def updates() -> AsyncIterator[AgentResponseUpdate]: + started[self.index].set() + await released[self.index].wait() + session.state["winner"] = self.index + yield AgentResponseUpdate(contents=[Content.from_text("ok")]) + + return updates() if stream else run_once() + + hosts = [InvocationsHostServer(PausingAgent(index)) for index in (0, 1)] + for host in hosts: + host.config.is_hosted = True + host.config.session_id = "" + + async def invoke(host: InvocationsHostServer, call_id: str) -> str: + with _request_context(call_id=call_id, user_id=user_id, session_id=sandbox_id): + response = await host._handle_invoke( # pyright: ignore[reportPrivateUsage] + _make_request({"message": "next", "stream": stream}, agent_session_id=sandbox_id) + ) + if isinstance(response, StreamingResponse): + return await _collect_stream(response) + return bytes(response.body).decode() + + tasks = [asyncio.create_task(invoke(host, f"call-{index + 1}")) for index, host in enumerate(hosts)] + try: + await asyncio.wait_for(asyncio.gather(*(event.wait() for event in started)), timeout=5) + released[0].set() + assert await asyncio.wait_for(tasks[0], timeout=5) == "ok" + released[1].set() + with pytest.raises(RuntimeError, match="Another request advanced this agent session"): + await asyncio.wait_for(tasks[1], timeout=5) + finally: + for event in released: + event.set() + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + verifier_agent = _make_agent(response_text="ok") + verifier = InvocationsHostServer(verifier_agent) + verifier.config.is_hosted = True + verifier.config.session_id = "" + with _request_context(call_id="call-3", user_id=user_id, session_id=sandbox_id): + await verifier._handle_invoke( # pyright: ignore[reportPrivateUsage] + _make_request({"message": "verify"}, agent_session_id=sandbox_id) + ) + assert verifier_agent.calls[0]["session"].state["winner"] == 0 + async def test_completed_sessions_are_released_and_state_is_restored(self) -> None: sessions: list[weakref.ReferenceType[AgentSession]] = [] turns: list[int] = [] diff --git a/python/packages/foundry_hosting/tests/test_state_store.py b/python/packages/foundry_hosting/tests/test_state_store.py index 65ca09acd6b..594c2dd89c3 100644 --- a/python/packages/foundry_hosting/tests/test_state_store.py +++ b/python/packages/foundry_hosting/tests/test_state_store.py @@ -466,6 +466,37 @@ async def test_agent_session_provider_selects_a_separate_logical_store() -> None assert store.set_item.call_args.args[1]["session_id"] == "runtime-id" +async def test_hosted_invocation_store_is_scoped_and_ignores_legacy_state() -> None: + context = _platform_context() + key = '["sandbox-1","user-1"]' + legacy = await FoundryStateStore.get_or_create("invocation_sessions", user_isolation=True) + old_session = AgentSession(session_id=key) + old_session.state["legacy"] = True + async with legacy: + await legacy.create_item(key, old_session.to_dict(), call_id=context.call_id) + + config = _config(is_hosted=True) + invocations = AgentSessionStoreProvider(store_name="invocation_sessions").get_store( + config=config, platform_context=context + ) + responses = AgentSessionStoreProvider().get_store(config=config, platform_context=context) + assert await invocations.get(key) is None + assert await responses.get(key) is None + + fresh = AgentSession(session_id=key) + fresh.state["new"] = True + await invocations.set(key, fresh) + restored_store = AgentSessionStoreProvider(store_name="invocation_sessions").get_store( + config=config, platform_context=context + ) + restored = await restored_store.get(key) + assert restored is not None and restored.state == {"new": True} + assert await responses.get(key) is None + async with legacy: + legacy_item = await legacy.get_item(key, call_id=context.call_id) + assert legacy_item is not None and legacy_item.value == old_session.to_dict() + + @pytest.mark.parametrize("store_name", ["", " \t", 123]) def test_session_store_name_must_be_nonempty(store_name: Any) -> None: with pytest.raises(ValueError, match="store_name must be a non-empty string"): diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/basic/README.md b/python/samples/04-hosting/foundry-hosted-agents/invocations/basic/README.md index 395d6cefb44..b0297ec194e 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/invocations/basic/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/basic/README.md @@ -1,12 +1,22 @@ # What this sample demonstrates -An [Agent Framework](https://github.com/microsoft/agent-framework) agent hosted using the **Invocations protocol** with session management. Unlike the Responses protocol, the Invocations protocol does **not** provide built-in server-side conversation history — this agent maintains an in-memory session store keyed by `agent_session_id`. In production, replace it with durable storage (Redis, Cosmos DB, etc.) so history survives restarts. +An [Agent Framework](https://github.com/microsoft/agent-framework) agent +hosted using the **Invocations protocol** with session management. Unlike +Responses, Invocations does **not** provide built-in conversation history. +The host persists its MAF `AgentSession` using the default file-based store +locally and Foundry storage when hosted, under the separate +`invocation_sessions` logical store. This basic agent does not configure a +history provider; add one if the model should remember previous messages. ## How It Works ### Model Integration -The agent uses `FoundryChatClient` from the Agent Framework to create a Responses client from the project endpoint and model deployment. When a request arrives, the handler looks up (or creates) a session by `session_id`, runs the agent with the user message and session context, and returns the reply. The agent supports both streaming (SSE events) and non-streaming (JSON) response modes. +The agent uses `FoundryChatClient` to create a Responses client from the +project endpoint and model deployment. When a request arrives, the host +restores (or creates) a MAF session, runs the agent with the user message +and session context, and persists its state. The agent supports streaming +and non-streaming response modes. See [main.py](main.py) for the full implementation. @@ -34,7 +44,9 @@ Or with streaming: curl -X POST http://localhost:8088/invocations -i -H "Content-Type: application/json" -d '{"message": "Hi", "stream": true}' ``` -The server will respond with a JSON object containing the response text. The `-i` flag in the `curl` command includes the HTTP response headers in the output, which includes the session ID that can be used for multi-turn conversations. Here is an example of the response: +The server responds with text. The `-i` flag in the `curl` command includes +HTTP response headers, including the session ID that can be reused on later +requests. Here is an example: ``` HTTP/1.1 200 @@ -51,12 +63,26 @@ Hi! How can I help? ### Multi-turn conversation -To have a multi-turn conversation with the agent, take the session ID from the response headers of the previous request and include it in URL parameters for the next request. For example: +To reuse the same sandbox and MAF session (not model message history in this +basic example), take the session ID from the previous response header and +include it in the URL query for the next request: ```bash curl -X POST http://localhost:8088/invocations?agent_session_id=9370b9d4-cd13-4436-a57f-03b843ac0e17 -i -H "Content-Type: application/json" -d '{"message": "How are you?"}' ``` +On Foundry, the `agent_session_id` **query parameter** routes an invocation to +the corresponding sandbox; an ID in the JSON body does not route it. The host +also requires platform user and call IDs. If the hosted container does not +receive `FOUNDRY_AGENT_SESSION_ID`, supply an explicit routed query ID even on +the first invocation (for example, [create a hosted session first](https://learn.microsoft.com/azure/foundry/agents/how-to/manage-hosted-sessions)). +Without either source, the host rejects the request rather than persisting +state under a generated ID. When the environment variable is present, a +different query ID is rejected. Locally, the SDK still provides a single-user +fallback for requests without an ID. +See the [state store guide](../../../../../packages/foundry_hosting/README.md#state-store) +for namespace and upgrade details. + ## Deploying the Agent to Foundry To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory.