From 6e0446da0825376557b35e868ed738c9b441bdf1 Mon Sep 17 00:00:00 2001 From: Pranav Pandit Date: Sat, 20 Jun 2026 12:09:01 +0000 Subject: [PATCH 01/11] [agentserver] Support container protocol v2.0.0 (per-request call ID) Implements container spec v2.0.0 across the agentserver packages: - Replace the `x-agent-user-isolation-key` / `x-agent-chat-isolation-key` headers with `x-agent-user-id` (global per-user key) and `x-agent-foundry-call-id` (opaque per-request call ID). - Add `FOUNDRY_AGENT_ID` env var support (`AgentConfig.agent_guid`). - Add a request-scoped platform context in core (`RequestContext`, `get_request_context`/`set_request_context`/`reset_request_context`) so the per-request call ID / user ID can be forwarded on outbound Foundry 1P calls and read by handler/tool code. - invocations + responses read the new inbound headers, bind them to the request context, and forward them on outbound Foundry Storage calls. - Rename the public `IsolationContext` -> `PlatformContext` (`user_id_key` / `call_id`); provider methods take `context=` instead of `isolation=`; in-process partition enforcement is now keyed on the user ID. - ghcopilot: the toolbox/MCP bridge forwards the platform identity headers on outbound Foundry toolbox calls (initialize, tools/list, tools/call). Packages bumped: core 2.0.0b7, responses 1.0.0b8, invocations 1.0.0b6, ghcopilot 1.0.0b3. The optimization package is unaffected (startup-time resolver, no inbound call ID). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure-ai-agentserver-core/CHANGELOG.md | 12 ++ .../azure/ai/agentserver/core/__init__.py | 12 ++ .../azure/ai/agentserver/core/_config.py | 14 ++ .../azure/ai/agentserver/core/_constants.py | 1 + .../ai/agentserver/core/_platform_headers.py | 29 ++- .../ai/agentserver/core/_request_context.py | 129 +++++++++++++ .../azure/ai/agentserver/core/_version.py | 2 +- .../tests/test_config.py | 23 +++ .../tests/test_request_context.py | 61 ++++++ .../CHANGELOG.md | 6 + .../ai/agentserver/githubcopilot/_toolbox.py | 8 +- .../ai/agentserver/githubcopilot/_version.py | 2 +- .../pyproject.toml | 4 +- .../tests/unit_tests/test_toolbox.py | 36 ++++ .../CHANGELOG.md | 10 + .../ai/agentserver/invocations/_invocation.py | 37 +++- .../ai/agentserver/invocations/_version.py | 2 +- .../CHANGELOG.md | 13 ++ .../azure-ai-agentserver-responses/README.md | 2 +- .../ai/agentserver/responses/__init__.py | 4 +- .../responses/_response_context.py | 56 +++--- .../ai/agentserver/responses/_version.py | 2 +- .../responses/hosting/_endpoint_handler.py | 173 ++++++++++-------- .../responses/hosting/_execution_context.py | 8 +- .../responses/hosting/_orchestrator.py | 50 ++--- .../responses/hosting/_runtime_state.py | 14 +- .../agentserver/responses/models/runtime.py | 4 +- .../ai/agentserver/responses/store/_base.py | 64 +++---- .../store/_foundry_logging_policy.py | 14 +- .../responses/store/_foundry_provider.py | 82 +++++---- .../ai/agentserver/responses/store/_memory.py | 62 +++---- .../contract/test_bg_isolation_propagation.py | 94 +++++----- .../contract/test_delete_eviction_race.py | 4 +- .../tests/contract/test_eager_eviction.py | 8 +- .../contract/test_persistence_failure.py | 36 ++-- .../contract/test_stream_event_lifecycle.py | 28 +-- .../contract/test_stream_provider_fallback.py | 28 +-- ....py => test_user_isolation_enforcement.py} | 86 ++++----- .../tests/unit/test_foundry_logging_policy.py | 34 ++-- .../unit/test_foundry_storage_provider.py | 98 +++++----- .../unit/test_in_memory_provider_crud.py | 2 +- .../tests/unit/test_platform_headers.py | 12 +- .../unit/test_response_context_input_items.py | 16 +- 43 files changed, 883 insertions(+), 499 deletions(-) create mode 100644 sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_request_context.py create mode 100644 sdk/agentserver/azure-ai-agentserver-core/tests/test_request_context.py rename sdk/agentserver/azure-ai-agentserver-responses/tests/contract/{test_chat_isolation_enforcement.py => test_user_isolation_enforcement.py} (88%) diff --git a/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md index 54b64fd3e6c1..9bef39c63eb6 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md @@ -1,5 +1,17 @@ # Release History +## 2.0.0b7 (Unreleased) + +### Features Added + +- Container protocol version `2.0.0` support: added the platform identity header constants `x-agent-user-id` (`USER_ID`) — the global, cross-agent per-user partition key — and `x-agent-foundry-call-id` (`FOUNDRY_CALL_ID`) — the opaque per-request call identifier — to the `_platform_headers` module. +- Added `FOUNDRY_AGENT_ID` environment variable support exposing the agent's stable GUID via `AgentConfig.agent_guid` and the `resolve_agent_guid()` helper. +- Added a request-scoped platform context: `RequestContext`, `get_request_context()`, `set_request_context()`, and `reset_request_context()`. Protocol packages bind the inbound per-request call ID and user ID so that handler code (and the SDK HTTP pipeline) can forward them on outbound Foundry platform calls. `RequestContext.platform_headers()` builds the headers to forward. + +### Breaking Changes + +- Replaced the `x-agent-user-isolation-key` / `x-agent-chat-isolation-key` header constants (`USER_ISOLATION_KEY` / `CHAT_ISOLATION_KEY`) with `x-agent-user-id` (`USER_ID`) and `x-agent-foundry-call-id` (`FOUNDRY_CALL_ID`) per container protocol version `2.0.0`. + ## 2.0.0b6 (2026-06-12) ### Bugs Fixed diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py index d360a00966a8..4ba3ca42b4b1 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py @@ -11,11 +11,13 @@ from azure.ai.agentserver.core import ( AgentConfig, AgentServerHost, + RequestContext, configure_observability, create_error_response, detach_context, end_span, flush_spans, + get_request_context, record_error, set_current_span, trace_stream, @@ -27,6 +29,12 @@ from ._config import AgentConfig from ._errors import create_error_response from ._middleware import InboundRequestLoggingMiddleware +from ._request_context import ( + RequestContext, + get_request_context, + reset_request_context, + set_request_context, +) from ._request_id import RequestIdMiddleware from ._server_version import build_server_version from ._tracing import ( @@ -44,6 +52,7 @@ "AgentConfig", "AgentServerHost", "InboundRequestLoggingMiddleware", + "RequestContext", "RequestIdMiddleware", "build_server_version", "configure_observability", @@ -51,8 +60,11 @@ "detach_context", "end_span", "flush_spans", + "get_request_context", "record_error", + "reset_request_context", "set_current_span", + "set_request_context", "trace_stream", ] __version__ = VERSION diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_config.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_config.py index 493f58794776..5804ec34b05a 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_config.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_config.py @@ -23,6 +23,7 @@ # ====================================================================== _ENV_FOUNDRY_AGENT_NAME = "FOUNDRY_AGENT_NAME" +_ENV_FOUNDRY_AGENT_ID = "FOUNDRY_AGENT_ID" _ENV_FOUNDRY_AGENT_VERSION = "FOUNDRY_AGENT_VERSION" _ENV_FOUNDRY_AGENT_INSTANCE_CLIENT_ID = "FOUNDRY_AGENT_INSTANCE_CLIENT_ID" _ENV_FOUNDRY_AGENT_BLUEPRINT_CLIENT_ID = "FOUNDRY_AGENT_BLUEPRINT_CLIENT_ID" @@ -60,6 +61,7 @@ class AgentConfig: # pylint: disable=too-many-instance-attributes :param agent_name: Agent name from ``FOUNDRY_AGENT_NAME``. :param agent_version: Agent version from ``FOUNDRY_AGENT_VERSION``. :param agent_id: Combined identifier (``"name:version"`` or ``"name"`` or ``""``). + :param agent_guid: Agent stable identifier (GUID) from ``FOUNDRY_AGENT_ID``. :param is_hosted: Whether the agent is running in a Foundry-hosted container environment, derived from ``FOUNDRY_HOSTING_ENVIRONMENT``. :param project_endpoint: Foundry project endpoint from ``FOUNDRY_PROJECT_ENDPOINT``. @@ -79,6 +81,7 @@ def __init__( agent_name: str, agent_version: str, agent_id: str, + agent_guid: str = "", is_hosted: bool, project_endpoint: str, project_id: str, @@ -92,6 +95,7 @@ def __init__( self.agent_name = agent_name self.agent_version = agent_version self.agent_id = agent_id + self.agent_guid = agent_guid self.is_hosted = is_hosted self.project_endpoint = project_endpoint self.project_id = project_id @@ -123,6 +127,7 @@ def from_env(cls) -> Self: agent_name=agent_name, agent_version=agent_version, agent_id=agent_id, + agent_guid=os.environ.get(_ENV_FOUNDRY_AGENT_ID, ""), is_hosted=bool(os.environ.get(_ENV_FOUNDRY_HOSTING_ENVIRONMENT, "")), project_endpoint=os.environ.get(_ENV_FOUNDRY_PROJECT_ENDPOINT, ""), project_id=os.environ.get(_ENV_FOUNDRY_PROJECT_ARM_ID, ""), @@ -315,6 +320,15 @@ def resolve_agent_id() -> str: return agent_name +def resolve_agent_guid() -> str: + """Resolve the agent stable GUID from the ``FOUNDRY_AGENT_ID`` environment variable. + + :return: The agent GUID, or an empty string if not set. + :rtype: str + """ + return os.environ.get(_ENV_FOUNDRY_AGENT_ID, "") + + def resolve_agent_blueprint_id() -> str: """Resolve the agent blueprint client ID from the ``FOUNDRY_AGENT_BLUEPRINT_CLIENT_ID`` environment variable. diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_constants.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_constants.py index 93e017f0ca8b..21f1286390cd 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_constants.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_constants.py @@ -8,6 +8,7 @@ class Constants: # Foundry identity FOUNDRY_AGENT_NAME = "FOUNDRY_AGENT_NAME" + FOUNDRY_AGENT_ID = "FOUNDRY_AGENT_ID" FOUNDRY_AGENT_VERSION = "FOUNDRY_AGENT_VERSION" FOUNDRY_PROJECT_ENDPOINT = "FOUNDRY_PROJECT_ENDPOINT" FOUNDRY_PROJECT_ARM_ID = "FOUNDRY_PROJECT_ARM_ID" diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_platform_headers.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_platform_headers.py index af69142d40bc..6525b78850e3 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_platform_headers.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_platform_headers.py @@ -21,7 +21,8 @@ **Request headers** (set by the platform or client): - :data:`REQUEST_ID` — client-provided correlation ID (echoed back on the response). -- :data:`USER_ISOLATION_KEY` / :data:`CHAT_ISOLATION_KEY` — platform isolation keys. +- :data:`USER_ID` — platform per-user (global, cross-agent) identity header. +- :data:`FOUNDRY_CALL_ID` — platform per-request opaque call identifier (protocol ``2.0.0``). - :data:`CLIENT_HEADER_PREFIX` — prefix for pass-through client headers. - :data:`TRACEPARENT` — W3C Trace Context propagation header. - :data:`CLIENT_REQUEST_ID` — Azure SDK client correlation header. @@ -50,16 +51,28 @@ Set on responses by protocol-specific session resolution logic. """ -# -- Platform isolation ----------------------------------------------------- +# -- Platform identity ------------------------------------------------------ -USER_ISOLATION_KEY: str = "x-agent-user-isolation-key" -"""The ``x-agent-user-isolation-key`` header — the platform-injected -partition key for user-private state. +USER_ID: str = "x-agent-user-id" +"""The ``x-agent-user-id`` header — the platform-injected global, +cross-agent partition key for per-user state. + +The same user yields the same value regardless of which agent they +interact with. Use it as the primary partition key for per-user data +(profiles, preferences, OAuth tokens, memory). Present on protocol +requests (not health probes); absent during local development. """ -CHAT_ISOLATION_KEY: str = "x-agent-chat-isolation-key" -"""The ``x-agent-chat-isolation-key`` header — the platform-injected -partition key for conversation-scoped state. +FOUNDRY_CALL_ID: str = "x-agent-foundry-call-id" +"""The ``x-agent-foundry-call-id`` header — the platform-minted opaque +per-request call identifier. + +Present only when the agent definition declares container protocol +version ``2.0.0``. The container **MUST** forward this value on all +outbound calls to Foundry platform services (Storage, Toolboxes/MCP +proxy, A2A) so that 1P services can resolve the server-side-stored +caller context. The container never parses it. Absent under protocol +version ``1.0.0`` or local development. """ # -- Client pass-through --------------------------------------------------- diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_request_context.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_request_context.py new file mode 100644 index 000000000000..aa5d5c2a35cd --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_request_context.py @@ -0,0 +1,129 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Request-scoped platform context for AgentServer containers. + +On container protocol version ``2.0.0`` the platform delivers a per-request +opaque call identifier (``x-agent-foundry-call-id``) plus a global per-user +identity header (``x-agent-user-id``). The container **MUST** forward these on +all outbound calls to Foundry platform services (Storage, Toolboxes/MCP proxy, +A2A) routed through ``FOUNDRY_PROJECT_ENDPOINT``. + +This module stores those values in a :class:`~contextvars.ContextVar` so that +they are available to any code running during request processing — including +handler code making raw ``httpx`` calls and the SDK's own HTTP pipeline — without +threading them through every call signature. + +Inbound protocol endpoints populate the context via :func:`set_request_context` +(internal); handler/tool code reads it via :func:`get_request_context` (public), +mirroring the hero samples in the container protocol spec:: + + from azure.ai.agentserver.core import get_request_context + + ctx = get_request_context() + headers = {"x-agent-foundry-call-id": ctx.call_id} if ctx.call_id else {} +""" + +from __future__ import annotations + +from contextvars import ContextVar, Token + +from ._platform_headers import FOUNDRY_CALL_ID, USER_ID + +__all__ = [ + "RequestContext", + "get_request_context", + "set_request_context", + "reset_request_context", +] + + +class RequestContext: + """Platform-supplied, request-scoped identity context. + + Populated by the protocol endpoint from inbound platform headers on every + request. All fields are ``None`` when the corresponding header was absent + (e.g. local development, or ``call_id`` under protocol version ``1.0.0``). + + :ivar call_id: Opaque per-request call identifier from ``x-agent-foundry-call-id`` + (protocol version ``2.0.0`` only). Forward verbatim on outbound Foundry + calls; never parse it. + :vartype call_id: str | None + :ivar user_id: Global, cross-agent per-user identity from ``x-agent-user-id``. + :vartype user_id: str | None + :ivar session_id: The resolved session ID for the request, when available. + :vartype session_id: str | None + """ + + __slots__ = ("call_id", "user_id", "session_id") + + def __init__( + self, + *, + call_id: str | None = None, + user_id: str | None = None, + session_id: str | None = None, + ) -> None: + self.call_id = call_id + self.user_id = user_id + self.session_id = session_id + + def platform_headers(self) -> dict[str, str]: + """Build the platform identity headers to forward on outbound 1P calls. + + Only headers whose source value is present (non-``None``) are included. + The managed-identity ``Authorization`` header is unaffected — these are + **additional** headers for caller-identity context and data partitioning. + + :return: A mapping of header name to value, suitable for merging into an + outbound request's headers. + :rtype: dict[str, str] + """ + headers: dict[str, str] = {} + if self.user_id is not None: + headers[USER_ID] = self.user_id + if self.call_id is not None: + headers[FOUNDRY_CALL_ID] = self.call_id + return headers + + +_EMPTY = RequestContext() + +_request_context_var: ContextVar[RequestContext] = ContextVar("agentserver_request_context") + + +def get_request_context() -> RequestContext: + """Return the platform context for the current request. + + Safe to call from anywhere during request processing. When no context has + been established (e.g. outside a request, or local development), an empty + :class:`RequestContext` with all-``None`` fields is returned. + + :return: The current request-scoped platform context. + :rtype: RequestContext + """ + return _request_context_var.get(_EMPTY) + + +def set_request_context(context: RequestContext) -> Token[RequestContext]: + """Bind ``context`` as the current request context (internal). + + Called by protocol endpoints at the start of request handling. The returned + token should be passed to :func:`reset_request_context` when the request + completes to avoid leaking context across requests on the same task. + + :param context: The request context to bind. + :type context: RequestContext + :return: A reset token for restoring the previous value. + :rtype: ~contextvars.Token + """ + return _request_context_var.set(context) + + +def reset_request_context(token: Token[RequestContext]) -> None: + """Restore the request context to its previous value (internal). + + :param token: The token returned by :func:`set_request_context`. + :type token: ~contextvars.Token + """ + _request_context_var.reset(token) diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_version.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_version.py index 2577b81a5658..369f0dcc3bea 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_version.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_version.py @@ -2,4 +2,4 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- -VERSION = "2.0.0b6" +VERSION = "2.0.0b7" diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/test_config.py b/sdk/agentserver/azure-ai-agentserver-core/tests/test_config.py index be194e6ec0fd..af18a8d84846 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/test_config.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/test_config.py @@ -37,3 +37,26 @@ def test_is_hosted_snapshotted_at_creation(self, monkeypatch: pytest.MonkeyPatch # Changing the env var after creation must not affect the already-created config. monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT") assert config.is_hosted is True + + +class TestAgentConfigAgentGuid: + """Tests for the FOUNDRY_AGENT_ID (agent_guid) resolution.""" + + def test_agent_guid_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FOUNDRY_AGENT_ID", "11111111-2222-3333-4444-555555555555") + config = AgentConfig.from_env() + assert config.agent_guid == "11111111-2222-3333-4444-555555555555" + + def test_agent_guid_empty_when_absent(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FOUNDRY_AGENT_ID", raising=False) + config = AgentConfig.from_env() + assert config.agent_guid == "" + + def test_agent_guid_distinct_from_composite_agent_id(self, monkeypatch: pytest.MonkeyPatch) -> None: + """agent_guid (GUID) is independent of the name:version composite agent_id.""" + monkeypatch.setenv("FOUNDRY_AGENT_NAME", "weather") + monkeypatch.setenv("FOUNDRY_AGENT_VERSION", "1") + monkeypatch.setenv("FOUNDRY_AGENT_ID", "guid-abc") + config = AgentConfig.from_env() + assert config.agent_id == "weather:1" + assert config.agent_guid == "guid-abc" diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/test_request_context.py b/sdk/agentserver/azure-ai-agentserver-core/tests/test_request_context.py new file mode 100644 index 000000000000..740a1ac82d91 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/test_request_context.py @@ -0,0 +1,61 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- +"""Unit tests for the request-scoped platform context.""" +from __future__ import annotations + +import asyncio + +from azure.ai.agentserver.core import ( + RequestContext, + get_request_context, + reset_request_context, + set_request_context, +) +from azure.ai.agentserver.core._platform_headers import FOUNDRY_CALL_ID, USER_ID + + +class TestRequestContext: + def test_default_is_empty(self) -> None: + ctx = get_request_context() + assert ctx.call_id is None + assert ctx.user_id is None + assert ctx.session_id is None + + def test_set_and_reset(self) -> None: + token = set_request_context( + RequestContext(call_id="c1", user_id="u1", session_id="s1") + ) + try: + ctx = get_request_context() + assert ctx.call_id == "c1" + assert ctx.user_id == "u1" + assert ctx.session_id == "s1" + finally: + reset_request_context(token) + assert get_request_context().call_id is None + + def test_platform_headers_includes_present_values(self) -> None: + ctx = RequestContext(call_id="cid", user_id="uid") + headers = ctx.platform_headers() + assert headers[USER_ID] == "uid" + assert headers[FOUNDRY_CALL_ID] == "cid" + + def test_platform_headers_omits_absent_values(self) -> None: + assert RequestContext().platform_headers() == {} + assert RequestContext(user_id="uid").platform_headers() == {USER_ID: "uid"} + assert RequestContext(call_id="cid").platform_headers() == {FOUNDRY_CALL_ID: "cid"} + + def test_context_propagates_into_child_task(self) -> None: + async def _run() -> RequestContext: + set_request_context(RequestContext(call_id="task-cid", user_id="task-uid")) + + async def _child() -> RequestContext: + return get_request_context() + + # Child task created in this scope inherits the current context. + return await asyncio.create_task(_child()) + + ctx = asyncio.run(_run()) + assert ctx.call_id == "task-cid" + assert ctx.user_id == "task-uid" diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md index c5242a31a831..23d7275cfac0 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 1.0.0b3 (Unreleased) + +### Features Added + +- Container protocol version `2.0.0` support: the toolbox MCP bridge now forwards the platform identity headers (`x-agent-foundry-call-id` and `x-agent-user-id`) from the inbound request context on all outbound Foundry toolbox calls (initialize, `tools/list`, `tools/call`). No-op under protocol version `1.0.0` or local development. + ## 1.0.0b2 (2026-04-24) ### Breaking Changes diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py index 3a49e512830b..92f6297cf26d 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py @@ -26,6 +26,8 @@ import httpx from copilot.tools import Tool, ToolResult +from azure.ai.agentserver.core import get_request_context # pylint: disable=no-name-in-module + logger = logging.getLogger("azure.ai.agentserver.githubcopilot") # Canary — proves which version of _toolbox.py is deployed. @@ -180,6 +182,10 @@ def _request_headers(self) -> Dict[str, str]: logger.warning("Failed to refresh token for MCP bridge", exc_info=True) if self._session_id: headers["mcp-session-id"] = self._session_id + # Forward platform identity headers (call ID / user ID) to the Foundry + # toolbox service on container protocol version 2.0.0. No-op when absent + # (protocol 1.0.0 or local development). + headers.update(get_request_context().platform_headers()) return headers async def initialize(self) -> str: @@ -196,7 +202,7 @@ async def initialize(self) -> str: resp = await self._client.post( self._endpoint, - headers=self._headers, + headers={**self._headers, **get_request_context().platform_headers()}, json={ "jsonrpc": "2.0", "id": self._next_id(), diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_version.py b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_version.py index 0dcf5333ec20..054e06e92c03 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_version.py +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_version.py @@ -3,4 +3,4 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # --------------------------------------------------------- -VERSION = "1.0.0b2" +VERSION = "1.0.0b3" diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/pyproject.toml b/sdk/agentserver/azure-ai-agentserver-ghcopilot/pyproject.toml index 6920f0fb4d39..443863caf127 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/pyproject.toml +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/pyproject.toml @@ -20,8 +20,8 @@ classifiers = [ keywords = ["azure", "azure sdk"] dependencies = [ - "azure-ai-agentserver-core>=2.0.0a1", - "azure-ai-agentserver-responses>=1.0.0a1", + "azure-ai-agentserver-core>=2.0.0b7", + "azure-ai-agentserver-responses>=1.0.0b8", "github-copilot-sdk>=0.2.0,<0.3.0", "azure-identity", "httpx>=0.24.0", diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py b/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py index 791972f426dd..1e516528e5d2 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py @@ -339,3 +339,39 @@ def test_default_schema_when_missing(self): mcp_tools = [{"name": "simple_tool", "description": "A tool"}] tools = _make_copilot_tools(bridge, mcp_tools) assert tools[0].parameters == {"type": "object", "properties": {}} + + +# --------------------------------------------------------------------------- +# McpBridge platform identity header forwarding (container protocol 2.0.0) +# --------------------------------------------------------------------------- + + +class TestMcpBridgePlatformHeaders: + """The MCP bridge forwards the inbound call ID / user ID on outbound calls.""" + + def test_request_headers_forward_platform_context(self): + from azure.ai.agentserver.core import ( + RequestContext, + reset_request_context, + set_request_context, + ) + + bridge = McpBridge("https://acct.services.ai.azure.com/toolboxes/t/mcp", {"X-Static": "1"}) + token = set_request_context(RequestContext(call_id="call-123", user_id="user-abc")) + try: + headers = bridge._request_headers() + finally: + reset_request_context(token) + + assert headers["x-agent-foundry-call-id"] == "call-123" + assert headers["x-agent-user-id"] == "user-abc" + assert headers["X-Static"] == "1" + + def test_request_headers_omit_platform_context_when_absent(self): + bridge = McpBridge("https://acct.services.ai.azure.com/toolboxes/t/mcp", {"X-Static": "1"}) + + headers = bridge._request_headers() + + assert "x-agent-foundry-call-id" not in headers + assert "x-agent-user-id" not in headers + assert headers["X-Static"] == "1" diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-invocations/CHANGELOG.md index 5d0d19f060a7..7f295ab4a188 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-invocations/CHANGELOG.md @@ -1,5 +1,15 @@ # Release History +## 1.0.0b6 (Unreleased) + +### Features Added + +- Container protocol version `2.0.0` support: reads `x-agent-user-id` and `x-agent-foundry-call-id` from inbound requests and binds them to the request-scoped platform context so they are forwarded on outbound Foundry platform calls. The values are also exposed on `request.state.user_id` and `request.state.call_id`. + +### Breaking Changes + +- Replaced `request.state.user_isolation_key` / `request.state.chat_isolation_key` with `request.state.user_id` / `request.state.call_id` per container protocol version `2.0.0`. + ## 1.0.0b5 (2026-06-12) ### Bugs Fixed diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation.py b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation.py index 116ff0d62546..4dc4053d10eb 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation.py @@ -22,15 +22,18 @@ from azure.ai.agentserver.core import ( # pylint: disable=no-name-in-module AgentServerHost, + RequestContext, create_error_response, + reset_request_context, + set_request_context, ) from azure.ai.agentserver.core._platform_headers import ( # pylint: disable=import-error,no-name-in-module - CHAT_ISOLATION_KEY, ERROR_DETAIL, ERROR_SOURCE, + FOUNDRY_CALL_ID, MAX_ERROR_DETAIL_LENGTH, PLATFORM_ERROR_TAG, - USER_ISOLATION_KEY, + USER_ID, ) from ._constants import InvocationConstants @@ -362,6 +365,7 @@ def _wrap_streaming_response( response: StreamingResponse, invocation_id: str, session_id: str, + platform_context: RequestContext | None = None, ) -> StreamingResponse: """Wrap streaming body iteration with invocation logging/tracing context. @@ -371,6 +375,9 @@ def _wrap_streaming_response( :type invocation_id: str :param session_id: Session identifier to stamp in context/logging. :type session_id: str + :param platform_context: Platform context to re-establish for outbound + 1P calls made during stream iteration (protocol 2.0.0). + :type platform_context: ~azure.ai.agentserver.core.RequestContext | None :return: The response with a wrapped body_iterator. :rtype: StreamingResponse """ @@ -380,6 +387,9 @@ async def _wrapped_body() -> AsyncIterator[Any]: # Re-establish the invocation context for the streaming task. stream_inv_token = _invocation_id_var.set(invocation_id) stream_session_token = _session_id_var.set(session_id) + stream_ctx_token = ( + set_request_context(platform_context) if platform_context is not None else None + ) try: async for chunk in original_iterator: yield chunk @@ -398,6 +408,8 @@ async def _wrapped_body() -> AsyncIterator[Any]: finally: _invocation_id_var.reset(stream_inv_token) _session_id_var.reset(stream_session_token) + if stream_ctx_token is not None: + reset_request_context(stream_ctx_token) response.body_iterator = _wrapped_body() return response @@ -417,9 +429,11 @@ async def _create_invocation_endpoint(self, request: Request) -> Response: session_id = _sanitize_id(raw_session_id, str(uuid.uuid4())) request.state.session_id = session_id - # Platform isolation headers — expose to handlers - request.state.user_isolation_key = request.headers.get(USER_ISOLATION_KEY, "") - request.state.chat_isolation_key = request.headers.get(CHAT_ISOLATION_KEY, "") + # Platform identity headers — expose to handlers + user_id = request.headers.get(USER_ID, "") + call_id = request.headers.get(FOUNDRY_CALL_ID, "") + request.state.user_id = user_id + request.state.call_id = call_id # Incoming baggage and trace context are already attached by # BaggageMiddleware and the Starlette OTel instrumentor. @@ -437,6 +451,14 @@ async def _create_invocation_endpoint(self, request: Request) -> Response: _ensure_log_filter() inv_token = _invocation_id_var.set(invocation_id) session_token = _session_id_var.set(session_id) + # Bind platform context so outbound 1P calls (and handler/tool code) can + # forward the per-request call ID and user ID (protocol 2.0.0). + platform_ctx = RequestContext( + call_id=call_id or None, + user_id=user_id or None, + session_id=session_id, + ) + ctx_token = set_request_context(platform_ctx) try: response = await self._dispatch_invoke(request) response.headers[InvocationConstants.INVOCATION_ID_HEADER] = invocation_id @@ -477,6 +499,7 @@ async def _create_invocation_endpoint(self, request: Request) -> Response: # tokens it sets for stream iteration. _invocation_id_var.reset(inv_token) _session_id_var.reset(session_token) + reset_request_context(ctx_token) try: _otel_context.detach(baggage_token) except ValueError: @@ -485,7 +508,9 @@ async def _create_invocation_endpoint(self, request: Request) -> Response: # Wrap streaming response body so exceptions during iteration are # recorded on the current trace span and logged as invocation errors. if isinstance(response, StreamingResponse): - response = self._wrap_streaming_response(response, invocation_id, session_id) + response = self._wrap_streaming_response( + response, invocation_id, session_id, platform_ctx + ) return response diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_version.py b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_version.py index eecd2a8e450f..ffa055f43119 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_version.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_version.py @@ -2,4 +2,4 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- -VERSION = "1.0.0b5" +VERSION = "1.0.0b6" diff --git a/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md index 6a35aabcf294..7ff0787306cc 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md @@ -1,5 +1,18 @@ # Release History +## 1.0.0b8 (Unreleased) + +### Features Added + +- Container protocol version `2.0.0` support: the per-request call ID (`x-agent-foundry-call-id`) and global user ID (`x-agent-user-id`) are read from inbound requests, exposed on `ResponseContext.platform_context`, and forwarded as headers on all outbound Foundry Storage calls. The call ID is also bound to the request-scoped platform context so handler/tool code making raw outbound calls can forward it. + +### Breaking Changes + +- Renamed the public `IsolationContext` type to `PlatformContext`. Its fields are now `user_id_key` (from `x-agent-user-id`) and `call_id` (from `x-agent-foundry-call-id`), replacing `user_key` / `chat_key`. +- `ResponseContext.isolation` is now `ResponseContext.platform_context`. +- Response provider protocol methods now accept a `context` keyword argument (previously `isolation`). +- In-process partition enforcement is now keyed on the user ID (`x-agent-user-id`) instead of the chat isolation key. + ## 1.0.0b7 (2026-05-25) ### Features Added diff --git a/sdk/agentserver/azure-ai-agentserver-responses/README.md b/sdk/agentserver/azure-ai-agentserver-responses/README.md index da041d5d926b..d67758f8345e 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/README.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/README.md @@ -91,7 +91,7 @@ The `ResponseContext` provides request-scoped state: |---|---| | `response_id` | Unique ID for this response | | `is_shutdown_requested` | Whether the server is draining | -| `isolation` | `IsolationContext` with `user_key` and `chat_key` for multi-tenant state partitioning | +| `platform_context` | `PlatformContext` with `user_id_key` (from `x-agent-user-id`) and `call_id` (from `x-agent-foundry-call-id`) for multi-tenant state partitioning and per-request caller-context forwarding | | `client_headers` | Dictionary of `x-client-*` headers forwarded from the platform (keys normalized to lowercase) | | `query_parameters` | Dictionary of query string parameters | | `get_input_items()` | Load resolved input items as `Item` subtypes | diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/__init__.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/__init__.py index 06ca699d9e16..c0b53ae1ad5b 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/__init__.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/__init__.py @@ -8,7 +8,7 @@ from . import _data_url as data_url from ._options import ResponsesServerOptions -from ._response_context import IsolationContext, ResponseContext +from ._response_context import PlatformContext, ResponseContext from .hosting._routing import ResponsesAgentServerHost from .models import CreateResponse, ResponseObject from .models._helpers import ( @@ -34,7 +34,7 @@ "data_url", # pylint: disable=naming-mismatch "ResponsesAgentServerHost", "ResponseContext", - "IsolationContext", + "PlatformContext", "ResponsesServerOptions", "ResponseProviderProtocol", "ResponseStreamProviderProtocol", diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_response_context.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_response_context.py index 055cac67c6ca..3072bc3c5488 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_response_context.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_response_context.py @@ -24,30 +24,34 @@ from .store._base import ResponseProviderProtocol -class IsolationContext: - """Platform-injected isolation keys for multi-tenant state partitioning. - - The Foundry hosting platform injects ``x-agent-user-isolation-key`` and - ``x-agent-chat-isolation-key`` headers on every protocol request (not on - health probes). These opaque strings serve as partition keys: - - - ``user_key`` — unique per user across all sessions; use for user-private state. - - ``chat_key`` — represents where conversation state lives; in 1:1 chats the - two keys are equal. - - When the headers are absent (e.g. local development), both keys are ``None``. - When the platform sends the header with an empty value, the key is an empty - string. Use ``is None`` to detect whether the header was present at all. +class PlatformContext: + """Platform-injected identity context for multi-tenant state partitioning. + + The Foundry hosting platform injects ``x-agent-user-id`` and (on container + protocol version ``2.0.0``) ``x-agent-foundry-call-id`` headers on every + protocol request (not on health probes): + + - ``user_id_key`` — global, cross-agent per-user partition key; the same user + yields the same value regardless of which agent they interact with. Use it + as the primary partition key for per-user state. + - ``call_id`` — opaque per-request call identifier (protocol ``2.0.0`` only). + Providers **MUST** forward it on outbound Foundry API calls so 1P services + can resolve the server-side-stored caller context. Never parse it. + + When a header is absent (e.g. local development, or ``call_id`` under protocol + version ``1.0.0``), the corresponding field is ``None``. When the platform + sends the header with an empty value, the field is an empty string. Use + ``is None`` to detect whether the header was present at all. """ - def __init__(self, *, user_key: str | None = None, chat_key: str | None = None) -> None: - self.user_key = user_key - """Partition key for user-private state (from ``x-agent-user-isolation-key``). + def __init__(self, *, user_id_key: str | None = None, call_id: str | None = None) -> None: + self.user_id_key = user_id_key + """Partition key for per-user state (from ``x-agent-user-id``). ``None`` when the header was not sent.""" - self.chat_key = chat_key - """Partition key for conversation/shared state (from ``x-agent-chat-isolation-key``). - ``None`` when the header was not sent.""" + self.call_id = call_id + """Opaque per-request call identifier (from ``x-agent-foundry-call-id``). + ``None`` when the header was not sent (protocol ``1.0.0`` or local dev).""" class ResponseContext: # pylint: disable=too-many-instance-attributes @@ -72,7 +76,7 @@ def __init__( history_limit: int = 100, client_headers: dict[str, str] | None = None, query_parameters: dict[str, str] | None = None, - isolation: IsolationContext | None = None, + platform_context: PlatformContext | None = None, prefetched_history_ids: list[str] | None = None, ) -> None: self.response_id = response_id @@ -82,7 +86,9 @@ def __init__( self.is_shutdown_requested: bool = False self.client_headers: dict[str, str] = client_headers or {} self.query_parameters: dict[str, str] = query_parameters or {} - self.isolation: IsolationContext = isolation if isolation is not None else IsolationContext() + self.platform_context: PlatformContext = ( + platform_context if platform_context is not None else PlatformContext() + ) self._provider: "ResponseProviderProtocol | None" = provider _items: list[Any] if input_items is not None: @@ -193,7 +199,7 @@ async def _get_input_items_resolved(self) -> Sequence[Item]: # Batch-resolve references if we have a provider and pending refs. if reference_ids and self._provider is not None: - resolved = await self._provider.get_items(reference_ids, isolation=self.isolation) + resolved = await self._provider.get_items(reference_ids, context=self.platform_context) for idx, pos in enumerate(reference_positions): if idx < len(resolved) and resolved[idx] is not None: converted = to_item(resolved[idx]) # type: ignore[arg-type] @@ -257,12 +263,12 @@ async def get_history(self) -> Sequence[OutputItem]: self._previous_response_id, self.conversation_id, self._history_limit, - isolation=self.isolation, + context=self.platform_context, ) if not item_ids: self._history_cache = () return self._history_cache - items = await self._provider.get_items(item_ids, isolation=self.isolation) + items = await self._provider.get_items(item_ids, context=self.platform_context) self._history_cache = tuple(item for item in items if item is not None) return self._history_cache diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_version.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_version.py index f2e49b063730..2392dd2c2c03 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_version.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_version.py @@ -4,4 +4,4 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- -VERSION = "1.0.0b7" +VERSION = "1.0.0b8" diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py index aa1517eb1fda..ceeb821941b4 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py @@ -22,13 +22,16 @@ from starlette.responses import JSONResponse, Response, StreamingResponse from azure.ai.agentserver.core import ( # pylint: disable=import-error,no-name-in-module + RequestContext, flush_spans, + reset_request_context, + set_request_context, ) from azure.ai.agentserver.core._platform_headers import ( # pylint: disable=import-error,no-name-in-module - CHAT_ISOLATION_KEY, CLIENT_HEADER_PREFIX, + FOUNDRY_CALL_ID, SESSION_ID, - USER_ISOLATION_KEY, + USER_ID, ) from azure.ai.agentserver.core._request_id import REQUEST_ID_STATE_KEY # pylint: disable=import-error,no-name-in-module from azure.ai.agentserver.responses.models._generated import ( @@ -39,7 +42,7 @@ from .._id_generator import IdGenerator from .._options import ResponsesServerOptions -from .._response_context import IsolationContext, ResponseContext +from .._response_context import PlatformContext, ResponseContext from ..models._helpers import get_input_expanded, to_output_item from ..models.runtime import ResponseExecution, ResponseModeFlags, build_cancelled_response, build_failed_response from ..store._base import ResponseProviderProtocol, ResponseStreamProviderProtocol @@ -101,22 +104,22 @@ logger = logging.getLogger("azure.ai.agentserver") -def _extract_isolation(request: Request) -> IsolationContext: - """Build an ``IsolationContext`` from platform-injected request headers. +def _extract_platform_context(request: Request) -> PlatformContext: + """Build a ``PlatformContext`` from platform-injected request headers. - Returns the isolation keys from ``x-agent-user-isolation-key`` and - ``x-agent-chat-isolation-key``. Keys are ``None`` when the header - is absent (e.g. local development) and empty string when sent - with no value. + Returns the per-user key from ``x-agent-user-id`` and the per-request call + ID from ``x-agent-foundry-call-id`` (protocol ``2.0.0`` only). Fields are + ``None`` when the header is absent (e.g. local development) and empty string + when sent with no value. :param request: The incoming Starlette HTTP request. :type request: Request - :return: An isolation context with user and chat keys. - :rtype: IsolationContext + :return: A platform context with the user ID key and call ID. + :rtype: PlatformContext """ - return IsolationContext( - user_key=request.headers.get(USER_ISOLATION_KEY), - chat_key=request.headers.get(CHAT_ISOLATION_KEY), + return PlatformContext( + user_id_key=request.headers.get(USER_ID), + call_id=request.headers.get(FOUNDRY_CALL_ID), ) @@ -415,8 +418,8 @@ def _build_execution_context( agent_session_id=agent_session_id, span=span, parsed=parsed, - user_isolation_key=request.headers.get(USER_ISOLATION_KEY), - chat_isolation_key=request.headers.get(CHAT_ISOLATION_KEY), + user_id=request.headers.get(USER_ID), + call_id=request.headers.get(FOUNDRY_CALL_ID), ) # Derive the public ResponseContext from the execution context. @@ -458,9 +461,9 @@ def _create_response_context( history_limit=self._runtime_options.default_fetch_history_count, client_headers=client_headers, query_parameters=dict(request.query_params), - isolation=IsolationContext( - user_key=ctx.user_isolation_key, - chat_key=ctx.chat_isolation_key, + platform_context=PlatformContext( + user_id_key=ctx.user_id, + call_id=ctx.call_id, ), prefetched_history_ids=ctx.prefetched_history_ids, ) @@ -497,12 +500,12 @@ async def _prefetch_history_ids( _hdrs = self._session_headers(agent_session_id) try: - _isolation = ctx.context.isolation if ctx.context else None + _context = ctx.context.platform_context if ctx.context else None prefetched = await self._provider.get_history_item_ids( ctx.previous_response_id, ctx.conversation_id, self._runtime_options.default_fetch_history_count, - isolation=_isolation, + context=_context, ) ctx.prefetched_history_ids = prefetched if ctx.context is not None: @@ -613,7 +616,7 @@ async def handle_create(self, request: Request) -> Response: # pylint: disable= logger.info( "Creating response %s: streaming=%s background=%s store=%s model=%s " "conversation_id=%s previous_response_id=%s " - "has_user_isolation_key=%s has_chat_isolation_key=%s", + "has_user_id=%s has_call_id=%s", ctx.response_id, ctx.stream, ctx.background, @@ -621,8 +624,8 @@ async def handle_create(self, request: Request) -> Response: # pylint: disable= ctx.model, ctx.conversation_id, ctx.previous_response_id, - ctx.user_isolation_key is not None, - ctx.chat_isolation_key is not None, + ctx.user_id is not None, + ctx.call_id is not None, ) # Eagerly validate conversation references before the handler runs. @@ -656,6 +659,15 @@ async def handle_create(self, request: Request) -> Response: # pylint: disable= rid_token = _response_id_var.set(response_id) cid_token = _conversation_id_var.set(ctx.conversation_id or "") str_token = _streaming_var.set(str(ctx.stream).lower()) + # Bind platform context so handler/tool code making raw outbound 1P calls + # can forward the per-request call ID and user ID (protocol 2.0.0). + pctx_token = set_request_context( + RequestContext( + call_id=ctx.call_id or None, + user_id=ctx.user_id or None, + session_id=agent_session_id, + ) + ) disconnect_task: asyncio.Task[None] | None = None try: @@ -754,6 +766,7 @@ async def _iter_with_cleanup(): # type: ignore[return] _response_id_var.reset(rid_token) _conversation_id_var.reset(cid_token) _streaming_var.reset(str_token) + reset_request_context(pctx_token) # Flush pending spans before the response is sent. # BatchSpanProcessor exports on a timer; in hosted sandboxes # the platform may freeze the process after the HTTP response, @@ -782,20 +795,20 @@ async def handle_get(self, request: Request) -> Response: # pylint: disable=too return format_error stream_replay_param = request.query_params.get("stream", "false").lower() == "true" - _isolation = _extract_isolation(request) + _context = _extract_platform_context(request) if stream_replay_param: logger.info( - "Getting response %s with SSE replay, has_user_isolation_key=%s has_chat_isolation_key=%s", + "Getting response %s with SSE replay, has_user_id=%s has_call_id=%s", response_id, - _isolation.user_key is not None, - _isolation.chat_key is not None, + _context.user_id_key is not None, + _context.call_id is not None, ) else: logger.info( - "Getting response %s, has_user_isolation_key=%s has_chat_isolation_key=%s", + "Getting response %s, has_user_id=%s has_call_id=%s", response_id, - _isolation.user_key is not None, - _isolation.chat_key is not None, + _context.user_id_key is not None, + _context.call_id is not None, ) record = await self._runtime_state.get(response_id) if record is None: @@ -803,12 +816,12 @@ async def handle_get(self, request: Request) -> Response: # pylint: disable=too request, response_id, stream_replay_param, - _isolation, + _context, _hdrs, ) - # Chat isolation enforcement on in-flight response - if not _RuntimeState.check_chat_isolation(record.chat_isolation_key, _isolation.chat_key): + # User isolation enforcement on in-flight response + if not _RuntimeState.check_user_isolation(record.user_id_key, _context.user_id_key): return _not_found(response_id, _hdrs) _refresh_background_status(record) @@ -873,7 +886,7 @@ async def _handle_get_fallback( # pylint: disable=too-many-return-statements request: Request, response_id: str, stream_replay: bool, - _isolation: "IsolationContext", + _context: "PlatformContext", _hdrs: dict[str, str], ) -> Response: """Provider fallback for GET when the record is not in runtime state. @@ -887,8 +900,8 @@ async def _handle_get_fallback( # pylint: disable=too-many-return-statements :type response_id: str :param stream_replay: Whether the client requested SSE replay. :type stream_replay: bool - :param _isolation: Isolation context from the request. - :type _isolation: IsolationContext + :param _context: Platform context from the request. + :type _context: PlatformContext :param _hdrs: Session headers to include on the response. :type _hdrs: dict[str, str] :return: Response. @@ -901,7 +914,7 @@ async def _handle_get_fallback( # pylint: disable=too-many-return-statements # Provider fallback: serve completed responses that are no longer in runtime state # (e.g., after a process restart). try: - response_obj = await self._provider.get_response(response_id, isolation=_isolation) + response_obj = await self._provider.get_response(response_id, context=_context) snapshot = response_obj.as_dict() logger.info( "Retrieved response %s: status=%s output_count=%d", @@ -930,7 +943,7 @@ async def _handle_get_fallback( # pylint: disable=too-many-return-statements replay_response = await self._try_replay_persisted_stream( request, response_id, - isolation=_isolation, + context=_context, headers=_hdrs, ) if replay_response is not None: @@ -942,7 +955,7 @@ async def _handle_get_fallback( # pylint: disable=too-many-return-statements # bg+stream-with-expired-TTL (we don't persist the stream flag), # so use a combined message. try: - persisted = await self._provider.get_response(response_id, isolation=_isolation) + persisted = await self._provider.get_response(response_id, context=_context) persisted_dict = persisted.as_dict() # B2: SSE replay requires background mode. if persisted_dict.get("background") is not True: @@ -1036,7 +1049,7 @@ async def _try_replay_persisted_stream( request: Request, response_id: str, *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, headers: dict[str, str] | None = None, ) -> Response | None: """Try to replay persisted SSE events from the stream provider. @@ -1049,8 +1062,8 @@ async def _try_replay_persisted_stream( :type request: Request :param response_id: The response identifier to replay. :type response_id: str - :keyword isolation: Optional isolation context for multi-tenant filtering. - :paramtype isolation: IsolationContext | None + :keyword context: Optional platform context for multi-tenant filtering. + :paramtype context: PlatformContext | None :keyword headers: Optional extra headers (e.g. session headers) to merge with SSE headers. :paramtype headers: dict[str, str] | None :return: A streaming replay response, an error response, or ``None``. @@ -1059,7 +1072,7 @@ async def _try_replay_persisted_stream( if self._stream_provider is None: return None try: - replay_events = await self._stream_provider.get_stream_events(response_id, isolation=isolation) + replay_events = await self._stream_provider.get_stream_events(response_id, context=context) if replay_events is None: return None parsed_cursor = self._parse_starting_after(request, headers) @@ -1090,12 +1103,12 @@ async def handle_delete(self, request: Request) -> Response: if format_error is not None: return format_error - _isolation = _extract_isolation(request) + _context = _extract_platform_context(request) logger.info( - "Deleting response %s, has_user_isolation_key=%s has_chat_isolation_key=%s", + "Deleting response %s, has_user_id=%s has_call_id=%s", response_id, - _isolation.user_key is not None, - _isolation.chat_key is not None, + _context.user_id_key is not None, + _context.call_id is not None, ) record = await self._runtime_state.get(response_id) if record is None: @@ -1104,14 +1117,14 @@ async def handle_delete(self, request: Request) -> Response: if await self._runtime_state.is_deleted(response_id): return _not_found(response_id, _hdrs) - result = await self._provider_delete_response(response_id, _isolation, _hdrs) + result = await self._provider_delete_response(response_id, _context, _hdrs) if result is not None: return result return _not_found(response_id, _hdrs) - # Chat isolation enforcement - if not _RuntimeState.check_chat_isolation(record.chat_isolation_key, _isolation.chat_key): + # User isolation enforcement + if not _RuntimeState.check_user_isolation(record.user_id_key, _context.user_id_key): return _not_found(response_id, _hdrs) # store=false responses are not deletable (FR-014) @@ -1135,14 +1148,14 @@ async def handle_delete(self, request: Request) -> Response: # persistence attempt, but persistence is best-effort and may not # have succeeded, so delegate to the provider path as a fallback. if record.mode_flags.store: - result = await self._provider_delete_response(response_id, _isolation, _hdrs) + result = await self._provider_delete_response(response_id, _context, _hdrs) if result is not None: return result return _not_found(response_id, _hdrs) if record.mode_flags.store: try: - await self._provider.delete_response(response_id, isolation=_extract_isolation(request)) + await self._provider.delete_response(response_id, context=_extract_platform_context(request)) except Exception: # pylint: disable=broad-exception-caught logger.warning("Best-effort provider delete failed for response_id=%s", response_id, exc_info=True) # Clean up persisted stream events @@ -1150,7 +1163,7 @@ async def handle_delete(self, request: Request) -> Response: try: await self._stream_provider.delete_stream_events( response_id, - isolation=_extract_isolation(request), + context=_extract_platform_context(request), ) except Exception: # pylint: disable=broad-exception-caught logger.debug( @@ -1169,7 +1182,7 @@ async def handle_delete(self, request: Request) -> Response: async def _provider_delete_response( self, response_id: str, - isolation: "IsolationContext", + context: "PlatformContext", headers: dict[str, str], ) -> Response | None: """Delete a response from the durable provider (storage). @@ -1185,19 +1198,19 @@ async def _provider_delete_response( :param response_id: The response ID to delete. :type response_id: str - :param isolation: Isolation context extracted from the request. - :type isolation: IsolationContext + :param context: Platform context extracted from the request. + :type context: PlatformContext :param headers: Session headers to include on the response. :type headers: dict[str, str] :return: A success/error response, or ``None`` if not found. :rtype: Response | None """ try: - await self._provider.delete_response(response_id, isolation=isolation) + await self._provider.delete_response(response_id, context=context) # Clean up persisted stream events if self._stream_provider is not None: try: - await self._stream_provider.delete_stream_events(response_id, isolation=isolation) + await self._stream_provider.delete_stream_events(response_id, context=context) except Exception: # pylint: disable=broad-exception-caught logger.debug( "Best-effort stream event delete failed for response_id=%s", @@ -1241,19 +1254,19 @@ async def handle_cancel(self, request: Request) -> Response: if format_error is not None: return format_error - _isolation = _extract_isolation(request) + _context = _extract_platform_context(request) logger.info( - "Cancelling response %s, has_user_isolation_key=%s has_chat_isolation_key=%s", + "Cancelling response %s, has_user_id=%s has_call_id=%s", response_id, - _isolation.user_key is not None, - _isolation.chat_key is not None, + _context.user_id_key is not None, + _context.call_id is not None, ) record = await self._runtime_state.get(response_id) if record is None: - return await self._handle_cancel_fallback(response_id, _isolation, _hdrs) + return await self._handle_cancel_fallback(response_id, _context, _hdrs) - # Chat isolation enforcement on in-flight response - if not _RuntimeState.check_chat_isolation(record.chat_isolation_key, _isolation.chat_key): + # User isolation enforcement on in-flight response + if not _RuntimeState.check_user_isolation(record.user_id_key, _context.user_id_key): return _not_found(response_id, _hdrs) _refresh_background_status(record) @@ -1296,7 +1309,7 @@ async def handle_cancel(self, request: Request) -> Response: # Persist cancelled state to durable store (B11: cancellation always wins) try: if record.response is not None: - await self._provider.update_response(record.response, isolation=_extract_isolation(request)) + await self._provider.update_response(record.response, context=_extract_platform_context(request)) except Exception: # pylint: disable=broad-exception-caught logger.debug("Best-effort cancel persist failed for response_id=%s", record.response_id, exc_info=True) @@ -1312,7 +1325,7 @@ async def handle_cancel(self, request: Request) -> Response: async def _handle_cancel_fallback( self, response_id: str, - _isolation: "IsolationContext", + _context: "PlatformContext", _hdrs: dict[str, str], ) -> Response: """Provider fallback for cancel when the record is not in runtime state. @@ -1323,15 +1336,15 @@ async def _handle_cancel_fallback( :param response_id: The response ID to cancel. :type response_id: str - :param _isolation: Isolation context from the request. - :type _isolation: IsolationContext + :param _context: Platform context from the request. + :type _context: PlatformContext :param _hdrs: Session headers to include on the response. :type _hdrs: dict[str, str] :return: Error or idempotent response. :rtype: Response """ try: - response_obj = await self._provider.get_response(response_id, isolation=_isolation) + response_obj = await self._provider.get_response(response_id, context=_context) persisted = response_obj.as_dict() # B1: background check comes first — non-bg responses always @@ -1380,19 +1393,19 @@ async def handle_input_items(self, request: Request) -> Response: if format_error is not None: return format_error - _isolation = _extract_isolation(request) + _context = _extract_platform_context(request) logger.info( - "Getting input items for response %s, has_user_isolation_key=%s has_chat_isolation_key=%s", + "Getting input items for response %s, has_user_id=%s has_call_id=%s", response_id, - _isolation.user_key is not None, - _isolation.chat_key is not None, + _context.user_id_key is not None, + _context.call_id is not None, ) - # Chat isolation enforcement for in-flight responses. After eviction, - # the provider (Foundry storage) enforces isolation server-side. + # User isolation enforcement for in-flight responses. After eviction, + # the provider (Foundry storage) enforces partitioning server-side. record = await self._runtime_state.get(response_id) if record is not None: - if not _RuntimeState.check_chat_isolation(record.chat_isolation_key, _isolation.chat_key): + if not _RuntimeState.check_user_isolation(record.user_id_key, _context.user_id_key): return _not_found(response_id, _hdrs) limit_raw = request.query_params.get("limit", "20") @@ -1412,7 +1425,7 @@ async def handle_input_items(self, request: Request) -> Response: before = request.query_params.get("before") try: - items = await self._provider.get_input_items(response_id, limit=100, ascending=True, isolation=_isolation) + items = await self._provider.get_input_items(response_id, limit=100, ascending=True, context=_context) except ValueError: return _deleted_response(response_id, _hdrs) except FoundryResourceNotFoundError: @@ -1424,7 +1437,7 @@ async def handle_input_items(self, request: Request) -> Response: return _error_response(exc, _hdrs) except KeyError: # Fall back to runtime_state for in-flight responses not yet persisted to provider. - # Chat isolation was already checked above when the record is in-flight. + # User isolation was already checked above when the record is in-flight. try: items = await self._runtime_state.get_input_items(response_id) except ValueError: diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_execution_context.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_execution_context.py index c27b7acb18e4..89e18252d305 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_execution_context.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_execution_context.py @@ -38,8 +38,8 @@ def __init__( parsed: CreateResponse, agent_session_id: str | None = None, context: ResponseContext | None = None, - user_isolation_key: str | None = None, - chat_isolation_key: str | None = None, + user_id: str | None = None, + call_id: str | None = None, prefetched_history_ids: list[str] | None = None, ) -> None: self.response_id = response_id @@ -56,6 +56,6 @@ def __init__( self.parsed = parsed self.agent_session_id = agent_session_id self.context = context - self.user_isolation_key = user_isolation_key - self.chat_isolation_key = chat_isolation_key + self.user_id = user_id + self.call_id = call_id self.prefetched_history_ids = prefetched_history_ids diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_orchestrator.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_orchestrator.py index 99a26a17ccb2..999d5d641105 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_orchestrator.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_orchestrator.py @@ -326,21 +326,21 @@ async def _run_background_non_stream( # pylint: disable=too-many-locals,too-man # Persist at response.created time for bg+store (FR-003) if store and provider is not None: try: - _isolation = context.isolation if context else None + _context = context.platform_context if context else None _response_obj = generated_models.ResponseObject(_initial_snapshot) _history_ids = ( await provider.get_history_item_ids( record.previous_response_id, None, history_limit, - isolation=_isolation, + context=_context, ) if record.previous_response_id else None ) _resolved_items = await _resolve_input_items_for_persistence(context, record.input_items) await provider.create_response( - _response_obj, _resolved_items, _history_ids, isolation=_isolation + _response_obj, _resolved_items, _history_ids, context=_context ) _provider_created = True except Exception as persist_exc: # pylint: disable=broad-exception-caught @@ -501,15 +501,15 @@ async def _run_background_non_stream( # pylint: disable=too-many-locals,too-man record.set_response_snapshot(storage_error_response) record.status = "failed" # type: ignore[assignment] else: - _isolation = context.isolation if context else None + _context = context.platform_context if context else None try: if _provider_created: - await provider.update_response(record.response, isolation=_isolation) + await provider.update_response(record.response, context=_context) else: # Response was never created (handler yielded nothing or # failed before response.created) — create instead of update. _resolved_items = await _resolve_input_items_for_persistence(context, record.input_items) - await provider.create_response(record.response, _resolved_items, None, isolation=_isolation) + await provider.create_response(record.response, _resolved_items, None, context=_context) except Exception as persist_exc: # pylint: disable=broad-exception-caught setattr(persist_exc, PLATFORM_ERROR_TAG, True) logger.error( @@ -602,7 +602,7 @@ def _make_ephemeral_record(ctx: "_ExecutionContext", state: "_PipelineState") -> previous_response_id=ctx.previous_response_id, agent_session_id=ctx.agent_session_id, conversation_id=ctx.conversation_id, - chat_isolation_key=ctx.chat_isolation_key, + user_id_key=ctx.user_id, ) # Stash on state so _finalize_stream can access persistence_failed state.bg_record = record @@ -919,11 +919,11 @@ async def _persist_and_resolve_terminal( self._apply_storage_error_replacement(ctx, state, record) else: record.response.background = record.mode_flags.background - _isolation = ctx.context.isolation if ctx.context else None + _context = ctx.context.platform_context if ctx.context else None try: if state.provider_created: # bg+stream: initial create already done at response.created — use update - await self._provider.update_response(record.response, isolation=_isolation) + await self._provider.update_response(record.response, context=_context) else: # non-bg stream or bg stream where initial create was never registered: # full create @@ -932,7 +932,7 @@ async def _persist_and_resolve_terminal( ctx.previous_response_id, None, self._runtime_options.default_fetch_history_count, - isolation=_isolation, + context=_context, ) if ctx.previous_response_id else None @@ -942,7 +942,7 @@ async def _persist_and_resolve_terminal( generated_models.ResponseObject(response_payload), _resolved_items, _history_ids, - isolation=_isolation, + context=_context, ) except Exception as persist_exc: # pylint: disable=broad-exception-caught setattr(persist_exc, PLATFORM_ERROR_TAG, True) @@ -1003,7 +1003,7 @@ async def _register_bg_execution( cancel_signal=ctx.cancellation_signal, agent_session_id=ctx.agent_session_id, conversation_id=ctx.conversation_id, - chat_isolation_key=ctx.chat_isolation_key, + user_id_key=ctx.user_id, ) execution.set_response_snapshot(generated_models.ResponseObject(initial_payload)) execution.subject = _ResponseEventSubject() @@ -1012,14 +1012,14 @@ async def _register_bg_execution( await state.bg_record.subject.publish(first_normalized) await self._runtime_state.add(execution) if ctx.store: - _isolation = ctx.context.isolation if ctx.context else None + _context = ctx.context.platform_context if ctx.context else None _initial_response_obj = generated_models.ResponseObject(initial_payload) _history_ids = ( await self._provider.get_history_item_ids( ctx.previous_response_id, None, self._runtime_options.default_fetch_history_count, - isolation=_isolation, + context=_context, ) if ctx.previous_response_id else None @@ -1027,7 +1027,7 @@ async def _register_bg_execution( _resolved_items = await _resolve_input_items_for_persistence(ctx.context, ctx.input_items) try: await self._provider.create_response( - _initial_response_obj, _resolved_items, _history_ids, isolation=_isolation + _initial_response_obj, _resolved_items, _history_ids, context=_context ) state.provider_created = True except Exception as persist_exc: # pylint: disable=broad-exception-caught @@ -1336,10 +1336,10 @@ async def _finalize_stream(self, ctx: _ExecutionContext, state: _PipelineState) # Persist SSE events for replay after process restart (not needed for cancelled). if record.status != "cancelled" and self._stream_provider is not None and state.handler_events: - _isolation = ctx.context.isolation if ctx.context else None + _context = ctx.context.platform_context if ctx.context else None try: await self._stream_provider.save_stream_events( - ctx.response_id, state.handler_events, isolation=_isolation + ctx.response_id, state.handler_events, context=_context ) except Exception: # pylint: disable=broad-exception-caught logger.warning( @@ -1419,7 +1419,7 @@ async def _finalize_stream(self, ctx: _ExecutionContext, state: _PipelineState) cancel_signal=ctx.cancellation_signal if ctx.background else None, agent_session_id=ctx.agent_session_id, conversation_id=ctx.conversation_id, - chat_isolation_key=ctx.chat_isolation_key, + user_id_key=ctx.user_id, ) execution.set_response_snapshot(generated_models.ResponseObject(response_payload)) # Copy persistence_failed from the ephemeral record if one was used @@ -1430,9 +1430,9 @@ async def _finalize_stream(self, ctx: _ExecutionContext, state: _PipelineState) # Persist SSE events for replay after eager eviction (bg+stream only). if ctx.background and ctx.store and self._stream_provider is not None and events: - _isolation = ctx.context.isolation if ctx.context else None + _context = ctx.context.platform_context if ctx.context else None try: - await self._stream_provider.save_stream_events(ctx.response_id, events, isolation=_isolation) + await self._stream_provider.save_stream_events(ctx.response_id, events, context=_context) except Exception: # pylint: disable=broad-exception-caught logger.warning( "Best-effort stream event persistence failed (response_id=%s)", @@ -1720,7 +1720,7 @@ async def run_sync(self, ctx: _ExecutionContext) -> dict[str, Any]: response_context=ctx.context, agent_session_id=ctx.agent_session_id, conversation_id=ctx.conversation_id, - chat_isolation_key=ctx.chat_isolation_key, + user_id_key=ctx.user_id, ) record.set_response_snapshot(generated_models.ResponseObject(response_payload)) @@ -1733,14 +1733,14 @@ async def run_sync(self, ctx: _ExecutionContext) -> dict[str, Any]: # Persist via provider (non-bg sync: single create at terminal state). # §3.1: Persistence failure replaces the response body with storage_error. try: - _isolation = ctx.context.isolation if ctx.context else None + _context = ctx.context.platform_context if ctx.context else None _response_obj = generated_models.ResponseObject(response_payload) _history_ids = ( await self._provider.get_history_item_ids( ctx.previous_response_id, None, self._runtime_options.default_fetch_history_count, - isolation=_isolation, + context=_context, ) if ctx.previous_response_id else None @@ -1750,7 +1750,7 @@ async def run_sync(self, ctx: _ExecutionContext) -> dict[str, Any]: _response_obj, _resolved_items, _history_ids, - isolation=_isolation, + context=_context, ) except Exception as persist_exc: # pylint: disable=broad-exception-caught logger.error( @@ -1818,7 +1818,7 @@ async def run_background(self, ctx: _ExecutionContext) -> dict[str, Any]: initial_agent_reference=ctx.agent_reference, agent_session_id=ctx.agent_session_id, conversation_id=ctx.conversation_id, - chat_isolation_key=ctx.chat_isolation_key, + user_id_key=ctx.user_id, ) # Register so GET can observe in-flight state diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_runtime_state.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_runtime_state.py index dfe14e77abf5..66faac3eb560 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_runtime_state.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_runtime_state.py @@ -112,24 +112,24 @@ async def mark_deleted(self, response_id: str) -> None: self._deleted_response_ids.add(response_id) @staticmethod - def check_chat_isolation(stored_key: str | None, request_chat_key: str | None) -> bool: - """Check whether the request chat key matches the creation-time key. + def check_user_isolation(stored_key: str | None, request_user_id_key: str | None) -> bool: + """Check whether the request user ID matches the creation-time key. Returns ``True`` if the request is allowed, ``False`` if it should be - rejected as not-found to prevent cross-chat information leakage. + rejected as not-found to prevent cross-user information leakage. No enforcement when the response was created without a key (backward compat). - :param stored_key: The chat isolation key stored at creation time, or ``None``. + :param stored_key: The user ID key stored at creation time, or ``None``. :type stored_key: str | None - :param request_chat_key: The chat key from the incoming request, or ``None``. - :type request_chat_key: str | None + :param request_user_id_key: The user ID key from the incoming request, or ``None``. + :type request_user_id_key: str | None :return: ``True`` if allowed, ``False`` if isolation mismatch. :rtype: bool """ if stored_key is None: return True # No enforcement when created without a key - return stored_key == request_chat_key + return stored_key == request_user_id_key async def get_input_items(self, response_id: str) -> list[OutputItem]: """Retrieve the full input item chain for a response, including ancestors. diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/models/runtime.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/models/runtime.py index 15dbf69f4810..b5fe56b32387 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/models/runtime.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/models/runtime.py @@ -101,7 +101,7 @@ def __init__( initial_agent_reference: AgentReference | dict[str, Any] | None = None, agent_session_id: str | None = None, conversation_id: str | None = None, - chat_isolation_key: str | None = None, + user_id_key: str | None = None, ) -> None: self.response_id = response_id self.mode_flags = mode_flags @@ -123,7 +123,7 @@ def __init__( self.initial_agent_reference = initial_agent_reference or {} self.agent_session_id = agent_session_id self.conversation_id = conversation_id - self.chat_isolation_key = chat_isolation_key + self.user_id_key = user_id_key self.response_created_signal: asyncio.Event = asyncio.Event() self.response_failed_before_events: bool = False self.persistence_failed: bool = False diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_base.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_base.py index 83adfe6bed52..92da541e2ea8 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_base.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_base.py @@ -9,7 +9,7 @@ from ..models._generated import OutputItem, ResponseObject, ResponseStreamEvent if TYPE_CHECKING: - from .._response_context import IsolationContext + from .._response_context import PlatformContext @runtime_checkable @@ -18,7 +18,7 @@ class ResponseProviderProtocol(Protocol): Implementations provide response envelope storage plus input/history item lookup. - Every operation accepts an optional ``isolation`` parameter (S-018). + Every operation accepts an optional ``context`` parameter (S-018). Implementations MUST use it to partition data in multi-tenant deployments. When ``None``, the provider operates without tenant scoping (suitable for local development). @@ -30,7 +30,7 @@ async def create_response( input_items: Iterable[OutputItem] | None, history_item_ids: Iterable[str] | None, *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, ) -> None: """Persist a new response envelope and optional input/history references. @@ -40,41 +40,41 @@ async def create_response( :type input_items: Iterable[OutputItem] | None :param history_item_ids: Optional history item IDs to link to the response. :type history_item_ids: Iterable[str] | None - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :rtype: None """ - async def get_response(self, response_id: str, *, isolation: IsolationContext | None = None) -> ResponseObject: + async def get_response(self, response_id: str, *, context: PlatformContext | None = None) -> ResponseObject: """Load one response envelope by ID. :param response_id: The unique identifier of the response to retrieve. :type response_id: str - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: The response envelope matching the given ID. :rtype: ~azure.ai.agentserver.responses.models._generated.ResponseObject :raises KeyError: If the response does not exist. """ ... - async def update_response(self, response: ResponseObject, *, isolation: IsolationContext | None = None) -> None: + async def update_response(self, response: ResponseObject, *, context: PlatformContext | None = None) -> None: """Persist an updated response envelope. :param response: The response envelope with updated fields to persist. :type response: ~azure.ai.agentserver.responses.models._generated.ResponseObject - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :rtype: None """ - async def delete_response(self, response_id: str, *, isolation: IsolationContext | None = None) -> None: + async def delete_response(self, response_id: str, *, context: PlatformContext | None = None) -> None: """Delete a response envelope by ID. :param response_id: The unique identifier of the response to delete. :type response_id: str - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :rtype: None :raises KeyError: If the response does not exist. """ @@ -87,7 +87,7 @@ async def get_input_items( after: str | None = None, before: str | None = None, *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, ) -> list[OutputItem]: """Get response input/history items for one response ID using cursor pagination. @@ -101,22 +101,22 @@ async def get_input_items( :type after: str | None :param before: Cursor ID; only return items before this ID. :type before: str | None - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: A list of output items matching the pagination criteria. :rtype: list[OutputItem] """ ... async def get_items( - self, item_ids: Iterable[str], *, isolation: IsolationContext | None = None + self, item_ids: Iterable[str], *, context: PlatformContext | None = None ) -> list[OutputItem | None]: """Get items by ID (missing IDs produce ``None`` entries). :param item_ids: The item identifiers to look up. :type item_ids: Iterable[str] - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: A list of output items in the same order as *item_ids*; missing items are ``None``. :rtype: list[OutputItem | None] """ @@ -128,7 +128,7 @@ async def get_history_item_ids( conversation_id: str | None, limit: int, *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, ) -> list[str]: """Get history item IDs for a conversation chain scope. @@ -138,8 +138,8 @@ async def get_history_item_ids( :type conversation_id: str | None :param limit: Maximum number of history item IDs to return. :type limit: int - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: A list of history item IDs within the given scope. :rtype: list[str] """ @@ -160,7 +160,7 @@ async def save_stream_events( response_id: str, events: list[ResponseStreamEvent], *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, ) -> None: """Persist the complete ordered list of SSE events for a response. @@ -171,8 +171,8 @@ async def save_stream_events( :type response_id: str :param events: Ordered list of event instances to persist. :type events: list[ResponseStreamEvent] - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :rtype: None """ @@ -180,14 +180,14 @@ async def get_stream_events( self, response_id: str, *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, ) -> list[ResponseStreamEvent] | None: """Retrieve the persisted SSE events for a response. :param response_id: The unique identifier of the response whose events to retrieve. :type response_id: str - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: The ordered list of event instances, or ``None`` if not found. :rtype: list[ResponseStreamEvent] | None """ @@ -196,7 +196,7 @@ async def delete_stream_events( self, response_id: str, *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, ) -> None: """Delete persisted SSE events for a response. @@ -206,7 +206,7 @@ async def delete_stream_events( :param response_id: The unique identifier of the response whose events to remove. :type response_id: str - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :rtype: None """ diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_logging_policy.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_logging_policy.py index fefe8960038a..fc112c851561 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_logging_policy.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_logging_policy.py @@ -17,11 +17,11 @@ from azure.ai.agentserver.core._platform_headers import ( # pylint: disable=import-error,no-name-in-module APIM_REQUEST_ID, - CHAT_ISOLATION_KEY, CLIENT_REQUEST_ID, + FOUNDRY_CALL_ID, REQUEST_ID, TRACEPARENT, - USER_ISOLATION_KEY, + USER_ID, ) from azure.core.pipeline import PipelineRequest, PipelineResponse from azure.core.pipeline.policies import AsyncHTTPPolicy @@ -95,8 +95,8 @@ async def send(self, request: PipelineRequest) -> PipelineResponse: client_request_id = http_request.headers.get(CLIENT_REQUEST_ID, "") traceparent = http_request.headers.get(TRACEPARENT, "") - has_user_isolation_key = USER_ISOLATION_KEY in http_request.headers - has_chat_isolation_key = CHAT_ISOLATION_KEY in http_request.headers + has_user_id = USER_ID in http_request.headers + has_call_id = FOUNDRY_CALL_ID in http_request.headers logger.debug( "Foundry storage %s %s starting " @@ -136,7 +136,7 @@ async def send(self, request: PipelineRequest) -> PipelineResponse: "Foundry storage %s %s -> %d (%.1fms, " "x-ms-client-request-id=%s, traceparent=%s, " "x-request-id=%s, apim-request-id=%s, " - "has_user_isolation_key=%s, has_chat_isolation_key=%s)", + "has_user_id=%s, has_call_id=%s)", method, url, status_code, @@ -145,8 +145,8 @@ async def send(self, request: PipelineRequest) -> PipelineResponse: traceparent, x_request_id, apim_request_id, - has_user_isolation_key, - has_chat_isolation_key, + has_user_id, + has_call_id, ) return response diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py index c37942e2e83c..70f173a986bf 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterable from urllib.parse import quote as _url_quote -from azure.ai.agentserver.core._platform_headers import CHAT_ISOLATION_KEY, PLATFORM_ERROR_TAG, USER_ISOLATION_KEY # pylint: disable=import-error,no-name-in-module +from azure.ai.agentserver.core._platform_headers import FOUNDRY_CALL_ID, PLATFORM_ERROR_TAG, USER_ID # pylint: disable=import-error,no-name-in-module from azure.core import AsyncPipelineClient from azure.core.credentials_async import AsyncTokenCredential from azure.core.exceptions import ServiceRequestError, ServiceResponseError @@ -31,7 +31,7 @@ from ._foundry_settings import FoundryStorageSettings if TYPE_CHECKING: - from .._response_context import IsolationContext + from .._response_context import PlatformContext _FOUNDRY_TOKEN_SCOPE = "https://ai.azure.com/.default" _JSON_CONTENT_TYPE = "application/json; charset=utf-8" @@ -68,20 +68,24 @@ def _encode(value: str) -> str: return _url_quote(value, safe="") -def _apply_isolation_headers(request: HttpRequest, isolation: IsolationContext | None) -> None: - """Add isolation key headers to an outbound HTTP request when present. +def _apply_platform_headers(request: HttpRequest, context: PlatformContext | None) -> None: + """Forward platform identity headers on an outbound HTTP request when present. + + On protocol version ``2.0.0`` the container is responsible for forwarding the + per-request call ID (``x-agent-foundry-call-id``) and the global user ID + (``x-agent-user-id``) on all outbound calls to Foundry platform services. :param request: The outbound HTTP request to modify. :type request: ~azure.core.rest.HttpRequest - :param isolation: Isolation context containing user/chat keys, or ``None``. - :type isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :param context: Platform context containing the user ID / call ID, or ``None``. + :type context: ~azure.ai.agentserver.responses.PlatformContext | None """ - if isolation is None: + if context is None: return - if isolation.user_key is not None: - request.headers[USER_ISOLATION_KEY] = isolation.user_key - if isolation.chat_key is not None: - request.headers[CHAT_ISOLATION_KEY] = isolation.chat_key + if context.user_id_key is not None: + request.headers[USER_ID] = context.user_id_key + if context.call_id is not None: + request.headers[FOUNDRY_CALL_ID] = context.call_id class FoundryStorageProvider: @@ -202,7 +206,7 @@ async def create_response( input_items: Iterable[OutputItem] | None, history_item_ids: Iterable[str] | None, *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, ) -> None: """Persist a new response with its associated input items and history. @@ -212,23 +216,23 @@ async def create_response( :type input_items: Iterable[OutputItem] | None :param history_item_ids: Item IDs from the prior conversation turn, if any. :type history_item_ids: Iterable[str] | None - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :raises FoundryApiError: On non-success HTTP response. """ body = serialize_create_request(response, input_items, history_item_ids) url = self._settings.build_url("responses") request = HttpRequest("POST", url, content=body, headers={"Content-Type": _JSON_CONTENT_TYPE}) - _apply_isolation_headers(request, isolation) + _apply_platform_headers(request, context) await self._send_storage_request(request) - async def get_response(self, response_id: str, *, isolation: IsolationContext | None = None) -> ResponseObject: + async def get_response(self, response_id: str, *, context: PlatformContext | None = None) -> ResponseObject: """Retrieve a stored response by its ID. :param response_id: The response identifier. :type response_id: str - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: The deserialized :class:`ResponseObject` model. :rtype: ResponseObject :raises FoundryResourceNotFoundError: If the response does not exist. @@ -236,17 +240,17 @@ async def get_response(self, response_id: str, *, isolation: IsolationContext | """ url = self._settings.build_url(f"responses/{_encode(response_id)}") request = HttpRequest("GET", url) - _apply_isolation_headers(request, isolation) + _apply_platform_headers(request, context) http_resp = await self._send_storage_request(request) return deserialize_response(http_resp.text()) - async def update_response(self, response: ResponseObject, *, isolation: IsolationContext | None = None) -> None: + async def update_response(self, response: ResponseObject, *, context: PlatformContext | None = None) -> None: """Persist an updated response snapshot. :param response: The updated response model. Must contain a valid ``id`` field. :type response: ResponseObject - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :raises FoundryResourceNotFoundError: If the response does not exist. :raises FoundryApiError: On other non-success HTTP response. """ @@ -254,22 +258,22 @@ async def update_response(self, response: ResponseObject, *, isolation: Isolatio body = serialize_response(response) url = self._settings.build_url(f"responses/{_encode(response_id)}") request = HttpRequest("POST", url, content=body, headers={"Content-Type": _JSON_CONTENT_TYPE}) - _apply_isolation_headers(request, isolation) + _apply_platform_headers(request, context) await self._send_storage_request(request) - async def delete_response(self, response_id: str, *, isolation: IsolationContext | None = None) -> None: + async def delete_response(self, response_id: str, *, context: PlatformContext | None = None) -> None: """Delete a stored response and its associated data. :param response_id: The response identifier. :type response_id: str - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :raises FoundryResourceNotFoundError: If the response does not exist. :raises FoundryApiError: On other non-success HTTP response. """ url = self._settings.build_url(f"responses/{_encode(response_id)}") request = HttpRequest("DELETE", url) - _apply_isolation_headers(request, isolation) + _apply_platform_headers(request, context) await self._send_storage_request(request) async def get_input_items( @@ -280,7 +284,7 @@ async def get_input_items( after: str | None = None, before: str | None = None, *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, ) -> list[OutputItem]: """Retrieve a page of input items for the given response. @@ -294,8 +298,8 @@ async def get_input_items( :type after: str | None :param before: End the page before this item ID (cursor-based pagination). :type before: str | None - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: A list of deserialized :class:`OutputItem` instances. :rtype: list[OutputItem] :raises FoundryResourceNotFoundError: If the response does not exist. @@ -312,12 +316,12 @@ async def get_input_items( url = self._settings.build_url(f"responses/{_encode(response_id)}/input_items", **extra) request = HttpRequest("GET", url) - _apply_isolation_headers(request, isolation) + _apply_platform_headers(request, context) http_resp = await self._send_storage_request(request) return deserialize_paged_items(http_resp.text()) async def get_items( - self, item_ids: Iterable[str], *, isolation: IsolationContext | None = None + self, item_ids: Iterable[str], *, context: PlatformContext | None = None ) -> list[OutputItem | None]: """Retrieve multiple items by their IDs in a single batch request. @@ -326,8 +330,8 @@ async def get_items( :param item_ids: The item identifiers to retrieve. :type item_ids: Iterable[str] - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: A list of :class:`OutputItem` instances (or ``None`` for missing items). :rtype: list[OutputItem | None] :raises FoundryApiError: On non-success HTTP response. @@ -336,7 +340,7 @@ async def get_items( body = serialize_batch_request(ids) url = self._settings.build_url("items/batch/retrieve") request = HttpRequest("POST", url, content=body, headers={"Content-Type": _JSON_CONTENT_TYPE}) - _apply_isolation_headers(request, isolation) + _apply_platform_headers(request, context) http_resp = await self._send_storage_request(request) return deserialize_items_array(http_resp.text()) @@ -346,7 +350,7 @@ async def get_history_item_ids( conversation_id: str | None, limit: int, *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, ) -> list[str]: """Retrieve the ordered list of item IDs that form the conversation history. @@ -356,8 +360,8 @@ async def get_history_item_ids( :type conversation_id: str | None :param limit: Maximum number of item IDs to return. :type limit: int - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: Ordered list of item ID strings. :rtype: list[str] :raises FoundryApiError: On non-success HTTP response. @@ -370,6 +374,6 @@ async def get_history_item_ids( url = self._settings.build_url("history/item_ids", **extra) request = HttpRequest("GET", url) - _apply_isolation_headers(request, isolation) + _apply_platform_headers(request, context) http_resp = await self._send_storage_request(request) return deserialize_history_ids(http_resp.text()) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_memory.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_memory.py index 03bce1659b30..8e8969922d6f 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_memory.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_memory.py @@ -11,7 +11,7 @@ from datetime import datetime, timedelta, timezone from typing import Any, AsyncIterator, Dict, Iterable -from .._response_context import IsolationContext +from .._response_context import PlatformContext from ..models._generated import OutputItem, ResponseObject, ResponseStreamEvent from ..models._helpers import get_conversation_id from ..models.runtime import ResponseExecution, ResponseModeFlags, ResponseStatus, StreamEventRecord, StreamReplayState @@ -76,7 +76,7 @@ async def create_response( input_items: Iterable[OutputItem] | None, history_item_ids: Iterable[str] | None, *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, ) -> None: """Persist a new response envelope and optional input/history references. @@ -89,8 +89,8 @@ async def create_response( :type input_items: Iterable[OutputItem] | None :param history_item_ids: Optional history item IDs to link to the response. :type history_item_ids: Iterable[str] | None - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :rtype: None :raises ValueError: If a non-deleted response with the same ID already exists. """ @@ -128,13 +128,13 @@ async def create_response( if conversation_id is not None: self._conversation_responses[conversation_id].append(response_id) - async def get_response(self, response_id: str, *, isolation: IsolationContext | None = None) -> ResponseObject: + async def get_response(self, response_id: str, *, context: PlatformContext | None = None) -> ResponseObject: """Retrieve one response envelope by identifier. :param response_id: The unique identifier of the response to retrieve. :type response_id: str - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: A deep copy of the stored response envelope. :rtype: ~azure.ai.agentserver.responses.models._generated.Response :raises KeyError: If the response does not exist or has been deleted. @@ -145,7 +145,7 @@ async def get_response(self, response_id: str, *, isolation: IsolationContext | raise KeyError(f"response '{response_id}' not found") return deepcopy(entry.response) - async def update_response(self, response: ResponseObject, *, isolation: IsolationContext | None = None) -> None: + async def update_response(self, response: ResponseObject, *, context: PlatformContext | None = None) -> None: """Update a stored response envelope. Replaces the stored response with a deep copy and updates @@ -153,8 +153,8 @@ async def update_response(self, response: ResponseObject, *, isolation: Isolatio :param response: The response envelope with updated fields. :type response: ~azure.ai.agentserver.responses.models._generated.Response - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :rtype: None :raises KeyError: If the response does not exist or has been deleted. """ @@ -168,15 +168,15 @@ async def update_response(self, response: ResponseObject, *, isolation: Isolatio entry.execution.set_response_snapshot(deepcopy(response)) entry.output_item_ids = self._store_output_items_unlocked(response) - async def delete_response(self, response_id: str, *, isolation: IsolationContext | None = None) -> None: + async def delete_response(self, response_id: str, *, context: PlatformContext | None = None) -> None: """Delete a stored response envelope by identifier. Marks the entry as deleted and clears the response payload. :param response_id: The unique identifier of the response to delete. :type response_id: str - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :rtype: None :raises KeyError: If the response does not exist or has already been deleted. """ @@ -195,7 +195,7 @@ async def get_input_items( after: str | None = None, before: str | None = None, *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, ) -> list[OutputItem]: """Retrieve input/history items for a response with basic cursor paging. @@ -212,8 +212,8 @@ async def get_input_items( :type after: str | None :param before: Cursor ID; only return items before this ID. :type before: str | None - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: A list of input/history items matching the pagination criteria. :rtype: list[OutputItem] :raises KeyError: If the response does not exist. @@ -254,7 +254,7 @@ async def get_items( self, item_ids: Iterable[str], *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, ) -> list[OutputItem | None]: """Retrieve items by ID, preserving request order. @@ -262,8 +262,8 @@ async def get_items( :param item_ids: The item identifiers to look up. :type item_ids: Iterable[str] - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: A list of output items in the same order as *item_ids*; missing items are ``None``. :rtype: list[OutputItem | None] """ @@ -278,7 +278,7 @@ async def get_history_item_ids( conversation_id: str | None, limit: int, *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, ) -> list[str]: """Resolve history item IDs from previous response and/or conversation scope. @@ -291,8 +291,8 @@ async def get_history_item_ids( :type conversation_id: str | None :param limit: Maximum number of history item IDs to return. :type limit: int - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: A list of history item IDs within the given scope. :rtype: list[str] """ @@ -518,7 +518,7 @@ async def save_stream_events( response_id: str, events: list[ResponseStreamEvent], *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, ) -> None: """Persist the complete ordered list of SSE events for ``response_id``. @@ -529,8 +529,8 @@ async def save_stream_events( :type response_id: str :param events: Ordered list of event instances. :type events: list[ResponseStreamEvent] - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :rtype: None """ now = datetime.now(timezone.utc) @@ -546,7 +546,7 @@ async def get_stream_events( self, response_id: str, *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, ) -> list[ResponseStreamEvent] | None: """Retrieve the persisted SSE events for ``response_id``, excluding expired events. @@ -555,8 +555,8 @@ async def get_stream_events( :param response_id: The unique identifier of the response whose events to retrieve. :type response_id: str - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :returns: A deep-copied list of event instances, or ``None`` if not found. :rtype: list[ResponseStreamEvent] | None """ @@ -574,14 +574,14 @@ async def delete_stream_events( self, response_id: str, *, - isolation: IsolationContext | None = None, + context: PlatformContext | None = None, ) -> None: """Delete persisted SSE events for ``response_id``. :param response_id: The unique identifier of the response whose events to remove. :type response_id: str - :keyword isolation: Isolation context for multi-tenant partitioning. - :paramtype isolation: ~azure.ai.agentserver.responses.IsolationContext | None + :keyword context: Platform context for multi-tenant partitioning. + :paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None :rtype: None """ async with self._locked(): diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_bg_isolation_propagation.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_bg_isolation_propagation.py index 0f5d7887692f..d9ce0b6f30d8 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_bg_isolation_propagation.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_bg_isolation_propagation.py @@ -1,15 +1,15 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""Contract test: isolation keys propagate to update_response in bg non-stream finalization. +"""Contract test: platform context propagates to update_response in bg non-stream finalization. -When a background non-stream response is created with isolation headers -(``x-agent-user-isolation-key`` / ``x-agent-chat-isolation-key``), the +When a background non-stream response is created with platform identity headers +(``x-agent-user-id`` / ``x-agent-foundry-call-id``), the finalization ``update_response`` call in :func:`_run_background_non_stream` -must forward the same isolation context. A missing ``isolation=`` kwarg +must forward the same platform context. A missing ``context=`` kwarg causes Foundry storage to return 404 (response exists in a different partition) and the response is stuck at ``in_progress`` forever. -Regression test for: missing isolation on update_response in +Regression test for: missing context on update_response in _run_background_non_stream finally block. """ @@ -20,7 +20,7 @@ from starlette.testclient import TestClient from azure.ai.agentserver.responses import ResponsesAgentServerHost -from azure.ai.agentserver.responses._response_context import IsolationContext +from azure.ai.agentserver.responses._response_context import PlatformContext from azure.ai.agentserver.responses.models._generated import OutputItem, ResponseObject from azure.ai.agentserver.responses.store._memory import InMemoryResponseProvider from azure.ai.agentserver.responses.streaming import ResponseEventStream @@ -30,12 +30,12 @@ # ─── Recording provider ─────────────────────────────────── class _RecordingProvider: - """Wraps InMemoryResponseProvider and records isolation kwargs on every call.""" + """Wraps InMemoryResponseProvider and records context kwargs on every call.""" def __init__(self) -> None: self._inner = InMemoryResponseProvider() - self.create_calls: list[IsolationContext | None] = [] - self.update_calls: list[IsolationContext | None] = [] + self.create_calls: list[PlatformContext | None] = [] + self.update_calls: list[PlatformContext | None] = [] async def create_response( self, @@ -43,20 +43,20 @@ async def create_response( input_items: Iterable[OutputItem] | None, history_item_ids: Iterable[str] | None, *, - isolation: Any = None, + context: Any = None, ) -> None: - self.create_calls.append(isolation) - await self._inner.create_response(response, input_items, history_item_ids, isolation=isolation) + self.create_calls.append(context) + await self._inner.create_response(response, input_items, history_item_ids, context=context) - async def get_response(self, response_id: str, *, isolation: Any = None) -> ResponseObject: - return await self._inner.get_response(response_id, isolation=isolation) + async def get_response(self, response_id: str, *, context: Any = None) -> ResponseObject: + return await self._inner.get_response(response_id, context=context) - async def update_response(self, response: ResponseObject, *, isolation: Any = None) -> None: - self.update_calls.append(isolation) - await self._inner.update_response(response, isolation=isolation) + async def update_response(self, response: ResponseObject, *, context: Any = None) -> None: + self.update_calls.append(context) + await self._inner.update_response(response, context=context) - async def delete_response(self, response_id: str, *, isolation: Any = None) -> None: - await self._inner.delete_response(response_id, isolation=isolation) + async def delete_response(self, response_id: str, *, context: Any = None) -> None: + await self._inner.delete_response(response_id, context=context) async def get_input_items( self, @@ -66,17 +66,17 @@ async def get_input_items( after: str | None = None, before: str | None = None, *, - isolation: Any = None, + context: Any = None, ) -> list[OutputItem]: - return await self._inner.get_input_items(response_id, limit, ascending, after, before, isolation=isolation) + return await self._inner.get_input_items(response_id, limit, ascending, after, before, context=context) async def get_items( self, item_ids: Iterable[str], *, - isolation: Any = None, + context: Any = None, ) -> list[OutputItem | None]: - return await self._inner.get_items(item_ids, isolation=isolation) + return await self._inner.get_items(item_ids, context=context) async def get_history_item_ids( self, @@ -84,10 +84,10 @@ async def get_history_item_ids( conversation_id: str | None, limit: int, *, - isolation: Any = None, + context: Any = None, ) -> list[str]: return await self._inner.get_history_item_ids( - previous_response_id, conversation_id, limit, isolation=isolation + previous_response_id, conversation_id, limit, context=context ) @@ -136,16 +136,16 @@ def _is_terminal() -> bool: # ─── Tests ──────────────────────────────────────────────── class TestBgNonStreamIsolationPropagation: - """Verify that isolation keys reach update_response during bg non-stream finalization.""" + """Verify that the platform context reaches update_response during bg non-stream finalization.""" def test_update_response_receives_isolation_with_both_keys(self) -> None: - """Both user and chat isolation keys must be forwarded to update_response.""" + """Both user ID and call ID must be forwarded to update_response.""" provider = _RecordingProvider() client = _build_client(provider) headers = { - "x-agent-user-isolation-key": "user_123", - "x-agent-chat-isolation-key": "chat_456", + "x-agent-user-id": "user_123", + "x-agent-foundry-call-id": "call_456", } r = client.post( "/responses", @@ -157,27 +157,27 @@ def test_update_response_receives_isolation_with_both_keys(self) -> None: _wait_for_terminal(client, response_id, headers=headers) - # FR-003: create_response at response.created time should have isolation + # FR-003: create_response at response.created time should have platform context assert len(provider.create_calls) >= 1 create_iso = provider.create_calls[0] - assert isinstance(create_iso, IsolationContext) - assert create_iso.user_key == "user_123" - assert create_iso.chat_key == "chat_456" + assert isinstance(create_iso, PlatformContext) + assert create_iso.user_id_key == "user_123" + assert create_iso.call_id == "call_456" - # Finalization: update_response must also have isolation + # Finalization: update_response must also have platform context assert len(provider.update_calls) >= 1, "update_response was never called" update_iso = provider.update_calls[0] - assert update_iso is not None, "update_response called without isolation (was None)" - assert isinstance(update_iso, IsolationContext) - assert update_iso.user_key == "user_123" - assert update_iso.chat_key == "chat_456" + assert update_iso is not None, "update_response called without context (was None)" + assert isinstance(update_iso, PlatformContext) + assert update_iso.user_id_key == "user_123" + assert update_iso.call_id == "call_456" def test_update_response_receives_isolation_with_user_key_only(self) -> None: - """Only user isolation key — should still propagate.""" + """Only user ID — should still propagate.""" provider = _RecordingProvider() client = _build_client(provider) - headers = {"x-agent-user-isolation-key": "user_only"} + headers = {"x-agent-user-id": "user_only"} r = client.post( "/responses", json={"model": "m", "input": "hi", "stream": False, "store": True, "background": True}, @@ -191,11 +191,11 @@ def test_update_response_receives_isolation_with_user_key_only(self) -> None: assert len(provider.update_calls) >= 1, "update_response was never called" update_iso = provider.update_calls[0] assert update_iso is not None - assert isinstance(update_iso, IsolationContext) - assert update_iso.user_key == "user_only" + assert isinstance(update_iso, PlatformContext) + assert update_iso.user_id_key == "user_only" def test_update_response_without_isolation_headers_passes_none_keys(self) -> None: - """No isolation headers — isolation context has None keys (but is still passed).""" + """No platform identity headers — platform context has None keys (but is still passed).""" provider = _RecordingProvider() client = _build_client(provider) @@ -210,7 +210,7 @@ def test_update_response_without_isolation_headers_passes_none_keys(self) -> Non assert len(provider.update_calls) >= 1, "update_response was never called" update_iso = provider.update_calls[0] - # Isolation context is passed but keys are None when headers absent - assert isinstance(update_iso, IsolationContext) - assert update_iso.user_key is None - assert update_iso.chat_key is None + # Platform context is passed but fields are None when headers absent + assert isinstance(update_iso, PlatformContext) + assert update_iso.user_id_key is None + assert update_iso.call_id is None diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_delete_eviction_race.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_delete_eviction_race.py index f7021fe6ede5..8beea7e0adef 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_delete_eviction_race.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_delete_eviction_race.py @@ -149,10 +149,10 @@ def test_delete_after_natural_eviction_succeeds_via_provider(self, monkeypatch: _fallback_delete_called = False _original_provider_delete = _ResponseEndpointHandler._provider_delete_response - async def _recording_fallback(self_handler: Any, response_id: str, isolation: Any, headers: Any) -> Any: + async def _recording_fallback(self_handler: Any, response_id: str, context: Any, headers: Any) -> Any: nonlocal _fallback_delete_called _fallback_delete_called = True - return await _original_provider_delete(self_handler, response_id, isolation, headers) + return await _original_provider_delete(self_handler, response_id, context, headers) monkeypatch.setattr(_ResponseEndpointHandler, "_provider_delete_response", _recording_fallback) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_eager_eviction.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_eager_eviction.py index a4bc8a50c5ad..7ae133e1ef4f 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_eager_eviction.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_eager_eviction.py @@ -390,14 +390,14 @@ async def test_try_evict_removes_record_so_isolation_falls_to_storage(self) -> N response_id=rid, mode_flags=ResponseModeFlags(stream=False, store=True, background=False), status="completed", - chat_isolation_key="my_key", + user_id_key="my_key", ) await state.add(record) # In-flight: static helper enforces isolation via the record's key - assert _RuntimeState.check_chat_isolation(record.chat_isolation_key, "my_key") is True - assert _RuntimeState.check_chat_isolation(record.chat_isolation_key, "wrong") is False - assert _RuntimeState.check_chat_isolation(record.chat_isolation_key, None) is False + assert _RuntimeState.check_user_isolation(record.user_id_key, "my_key") is True + assert _RuntimeState.check_user_isolation(record.user_id_key, "wrong") is False + assert _RuntimeState.check_user_isolation(record.user_id_key, None) is False await state.try_evict(rid) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_persistence_failure.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_persistence_failure.py index 7b18a651ffaa..7c969ef2d79f 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_persistence_failure.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_persistence_failure.py @@ -63,25 +63,25 @@ async def create_response( input_items: Iterable[Any] | None, history_item_ids: Iterable[str] | None, *, - isolation: Any = None, + context: Any = None, ) -> None: self.create_called = True if self.fail_on_create: raise RuntimeError("Simulated storage failure") - return await self._inner.create_response(response, input_items, history_item_ids, isolation=isolation) + return await self._inner.create_response(response, input_items, history_item_ids, context=context) - async def update_response(self, response: Any, *, isolation: Any = None) -> None: + async def update_response(self, response: Any, *, context: Any = None) -> None: self.update_called = True if self.fail_on_update: raise RuntimeError("Simulated storage failure") - return await self._inner.update_response(response, isolation=isolation) + return await self._inner.update_response(response, context=context) - async def get_response(self, response_id: str, *, isolation: Any = None) -> Any: - return await self._inner.get_response(response_id, isolation=isolation) + async def get_response(self, response_id: str, *, context: Any = None) -> Any: + return await self._inner.get_response(response_id, context=context) - async def delete_response(self, response_id: str, *, isolation: Any = None) -> None: + async def delete_response(self, response_id: str, *, context: Any = None) -> None: self.delete_called = True - return await self._inner.delete_response(response_id, isolation=isolation) + return await self._inner.delete_response(response_id, context=context) async def get_history_item_ids( self, @@ -89,9 +89,9 @@ async def get_history_item_ids( before: str | None, limit: int, *, - isolation: Any = None, + context: Any = None, ) -> list[str] | None: - return await self._inner.get_history_item_ids(response_id, before, limit, isolation=isolation) + return await self._inner.get_history_item_ids(response_id, before, limit, context=context) async def get_input_items( self, @@ -99,23 +99,23 @@ async def get_input_items( *, limit: int = 100, ascending: bool = True, - isolation: Any = None, + context: Any = None, ) -> list[Any]: - return await self._inner.get_input_items(response_id, limit=limit, ascending=ascending, isolation=isolation) + return await self._inner.get_input_items(response_id, limit=limit, ascending=ascending, context=context) # ResponseStreamProviderProtocol delegation - async def save_stream_events(self, response_id: str, events: Any, *, isolation: Any = None) -> None: + async def save_stream_events(self, response_id: str, events: Any, *, context: Any = None) -> None: if hasattr(self._inner, "save_stream_events"): - return await self._inner.save_stream_events(response_id, events, isolation=isolation) + return await self._inner.save_stream_events(response_id, events, context=context) - async def get_stream_events(self, response_id: str, *, isolation: Any = None) -> Any: + async def get_stream_events(self, response_id: str, *, context: Any = None) -> Any: if hasattr(self._inner, "get_stream_events"): - return await self._inner.get_stream_events(response_id, isolation=isolation) + return await self._inner.get_stream_events(response_id, context=context) return None - async def delete_stream_events(self, response_id: str, *, isolation: Any = None) -> None: + async def delete_stream_events(self, response_id: str, *, context: Any = None) -> None: if hasattr(self._inner, "delete_stream_events"): - return await self._inner.delete_stream_events(response_id, isolation=isolation) + return await self._inner.delete_stream_events(response_id, context=context) def _make_app_with_failing_provider( diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_stream_event_lifecycle.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_stream_event_lifecycle.py index c245b23c146c..4b692142b993 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_stream_event_lifecycle.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_stream_event_lifecycle.py @@ -53,18 +53,18 @@ async def create_response( input_items: Iterable[OutputItem] | None, history_item_ids: Iterable[str] | None, *, - isolation: Any = None, + context: Any = None, ) -> None: - await self._inner.create_response(response, input_items, history_item_ids, isolation=isolation) + await self._inner.create_response(response, input_items, history_item_ids, context=context) - async def get_response(self, response_id: str, *, isolation: Any = None) -> ResponseObject: - return await self._inner.get_response(response_id, isolation=isolation) + async def get_response(self, response_id: str, *, context: Any = None) -> ResponseObject: + return await self._inner.get_response(response_id, context=context) - async def update_response(self, response: ResponseObject, *, isolation: Any = None) -> None: - await self._inner.update_response(response, isolation=isolation) + async def update_response(self, response: ResponseObject, *, context: Any = None) -> None: + await self._inner.update_response(response, context=context) - async def delete_response(self, response_id: str, *, isolation: Any = None) -> None: - await self._inner.delete_response(response_id, isolation=isolation) + async def delete_response(self, response_id: str, *, context: Any = None) -> None: + await self._inner.delete_response(response_id, context=context) async def get_input_items( self, @@ -74,17 +74,17 @@ async def get_input_items( after: str | None = None, before: str | None = None, *, - isolation: Any = None, + context: Any = None, ) -> list[OutputItem]: - return await self._inner.get_input_items(response_id, limit, ascending, after, before, isolation=isolation) + return await self._inner.get_input_items(response_id, limit, ascending, after, before, context=context) async def get_items( self, item_ids: Iterable[str], *, - isolation: Any = None, + context: Any = None, ) -> list[OutputItem | None]: - return await self._inner.get_items(item_ids, isolation=isolation) + return await self._inner.get_items(item_ids, context=context) async def get_history_item_ids( self, @@ -92,9 +92,9 @@ async def get_history_item_ids( conversation_id: str | None, limit: int, *, - isolation: Any = None, + context: Any = None, ) -> list[str]: - return await self._inner.get_history_item_ids(previous_response_id, conversation_id, limit, isolation=isolation) + return await self._inner.get_history_item_ids(previous_response_id, conversation_id, limit, context=context) # ──────────────────────────────────────── diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_stream_provider_fallback.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_stream_provider_fallback.py index dbb8813c078d..e7f7008594dd 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_stream_provider_fallback.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_stream_provider_fallback.py @@ -49,18 +49,18 @@ async def create_response( input_items: Iterable[OutputItem] | None, history_item_ids: Iterable[str] | None, *, - isolation: Any = None, + context: Any = None, ) -> None: - await self._inner.create_response(response, input_items, history_item_ids, isolation=isolation) + await self._inner.create_response(response, input_items, history_item_ids, context=context) - async def get_response(self, response_id: str, *, isolation: Any = None) -> ResponseObject: - return await self._inner.get_response(response_id, isolation=isolation) + async def get_response(self, response_id: str, *, context: Any = None) -> ResponseObject: + return await self._inner.get_response(response_id, context=context) - async def update_response(self, response: ResponseObject, *, isolation: Any = None) -> None: - await self._inner.update_response(response, isolation=isolation) + async def update_response(self, response: ResponseObject, *, context: Any = None) -> None: + await self._inner.update_response(response, context=context) - async def delete_response(self, response_id: str, *, isolation: Any = None) -> None: - await self._inner.delete_response(response_id, isolation=isolation) + async def delete_response(self, response_id: str, *, context: Any = None) -> None: + await self._inner.delete_response(response_id, context=context) async def get_input_items( self, @@ -70,7 +70,7 @@ async def get_input_items( after: str | None = None, before: str | None = None, *, - isolation: Any = None, + context: Any = None, ) -> list[OutputItem]: return await self._inner.get_input_items( response_id, @@ -78,16 +78,16 @@ async def get_input_items( ascending, after, before, - isolation=isolation, + context=context, ) async def get_items( self, item_ids: Iterable[str], *, - isolation: Any = None, + context: Any = None, ) -> list[OutputItem | None]: - return await self._inner.get_items(item_ids, isolation=isolation) + return await self._inner.get_items(item_ids, context=context) async def get_history_item_ids( self, @@ -95,13 +95,13 @@ async def get_history_item_ids( conversation_id: str | None, limit: int, *, - isolation: Any = None, + context: Any = None, ) -> list[str]: return await self._inner.get_history_item_ids( previous_response_id, conversation_id, limit, - isolation=isolation, + context=context, ) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_chat_isolation_enforcement.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_user_isolation_enforcement.py similarity index 88% rename from sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_chat_isolation_enforcement.py rename to sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_user_isolation_enforcement.py index c472306c1c37..745faaf39563 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_chat_isolation_enforcement.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_user_isolation_enforcement.py @@ -1,11 +1,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""Contract tests for chat isolation key enforcement across all endpoints. +"""Contract tests for user ID (user isolation) enforcement across all endpoints. -When a response is created with an ``x-agent-chat-isolation-key`` header, +When a response is created with an ``x-agent-user-id`` header, all subsequent GET, Cancel, DELETE, and InputItems requests must include the same key. Mismatched or missing keys return an indistinguishable 404 -to prevent cross-chat information leakage. +to prevent cross-user information leakage. Backward-compatible: no enforcement when the response was created without a key. """ @@ -41,7 +41,7 @@ def _make_client(handler=_noop_handler) -> TestClient: return TestClient(host) -def _create_response(client: TestClient, *, chat_key: str | None = None, **overrides) -> dict[str, Any]: +def _create_response(client: TestClient, *, user_id_key: str | None = None, **overrides) -> dict[str, Any]: """Create a response and return the parsed JSON body.""" payload = { "model": "m", @@ -49,8 +49,8 @@ def _create_response(client: TestClient, *, chat_key: str | None = None, **overr **overrides, } headers: dict[str, str] = {} - if chat_key is not None: - headers["x-agent-chat-isolation-key"] = chat_key + if user_id_key is not None: + headers["x-agent-user-id"] = user_id_key r = client.post("/responses", json=payload, headers=headers) assert r.status_code == 200, f"create failed: {r.status_code} {r.text}" return r.json() @@ -212,8 +212,8 @@ def _build_async_client(handler: Any) -> _AsyncAsgiClient: # ── GET with isolation ──────────────────────────────────── -class TestGetChatIsolation: - """GET /responses/{id} with chat isolation key enforcement. +class TestGetUserIsolation: + """GET /responses/{id} with user ID enforcement. In-flight isolation is enforced locally by the endpoint handler. After eviction, the Foundry storage provider enforces isolation @@ -223,16 +223,16 @@ class TestGetChatIsolation: """ def test_get_matching_key_returns_200(self) -> None: - """GET with the same chat key that was used at creation → 200.""" + """GET with the same user ID that was used at creation → 200.""" client = _make_client() - resp = _create_response(client, chat_key="key_A") - _wait_for_terminal(client, resp["id"], **{"x-agent-chat-isolation-key": "key_A"}) - r = client.get(f"/responses/{resp['id']}", headers={"x-agent-chat-isolation-key": "key_A"}) + resp = _create_response(client, user_id_key="key_A") + _wait_for_terminal(client, resp["id"], **{"x-agent-user-id": "key_A"}) + r = client.get(f"/responses/{resp['id']}", headers={"x-agent-user-id": "key_A"}) assert r.status_code == 200 @pytest.mark.asyncio async def test_get_mismatched_key_returns_404(self) -> None: - """GET with a different chat key on in-flight response → 404.""" + """GET with a different user ID on in-flight response → 404.""" handler = _make_cancellable_bg_handler() client = _build_async_client(handler) response_id = IdGenerator.new_response_id() @@ -246,14 +246,14 @@ async def test_get_mismatched_key_returns_404(self) -> None: "background": True, "stream": True, }, - headers={"x-agent-chat-isolation-key": "key_A"}, + headers={"x-agent-user-id": "key_A"}, ) ) try: await asyncio.wait_for(handler.started.wait(), timeout=5.0) r = await client.get( f"/responses/{response_id}", - headers={"x-agent-chat-isolation-key": "key_B"}, + headers={"x-agent-user-id": "key_B"}, ) assert r.status_code == 404 finally: @@ -267,7 +267,7 @@ async def test_get_mismatched_key_returns_404(self) -> None: @pytest.mark.asyncio async def test_get_missing_key_when_created_with_key_returns_404(self) -> None: - """GET without chat key when response was created with one → 404.""" + """GET without user ID when response was created with one → 404.""" handler = _make_cancellable_bg_handler() client = _build_async_client(handler) response_id = IdGenerator.new_response_id() @@ -281,7 +281,7 @@ async def test_get_missing_key_when_created_with_key_returns_404(self) -> None: "background": True, "stream": True, }, - headers={"x-agent-chat-isolation-key": "key_A"}, + headers={"x-agent-user-id": "key_A"}, ) ) try: @@ -303,7 +303,7 @@ def test_get_created_without_key_any_request_returns_200(self) -> None: resp = _create_response(client) _wait_for_terminal(client, resp["id"]) # With a key - r = client.get(f"/responses/{resp['id']}", headers={"x-agent-chat-isolation-key": "any_key"}) + r = client.get(f"/responses/{resp['id']}", headers={"x-agent-user-id": "any_key"}) assert r.status_code == 200 # Without a key r = client.get(f"/responses/{resp['id']}") @@ -325,14 +325,14 @@ async def test_get_404_error_body_is_standard(self) -> None: "background": True, "stream": True, }, - headers={"x-agent-chat-isolation-key": "key_A"}, + headers={"x-agent-user-id": "key_A"}, ) ) try: await asyncio.wait_for(handler.started.wait(), timeout=5.0) r = await client.get( f"/responses/{response_id}", - headers={"x-agent-chat-isolation-key": "key_WRONG"}, + headers={"x-agent-user-id": "key_WRONG"}, ) assert r.status_code == 404 body = r.json() @@ -351,8 +351,8 @@ async def test_get_404_error_body_is_standard(self) -> None: # ── DELETE with isolation ──────────────────────────────── -class TestDeleteChatIsolation: - """DELETE /responses/{id} with chat isolation key enforcement. +class TestDeleteUserIsolation: + """DELETE /responses/{id} with user ID enforcement. In-flight isolation is enforced locally; after eviction, isolation is enforced by the Foundry storage provider server-side. @@ -360,9 +360,9 @@ class TestDeleteChatIsolation: def test_delete_matching_key_returns_200(self) -> None: client = _make_client() - resp = _create_response(client, chat_key="key_A") - _wait_for_terminal(client, resp["id"], **{"x-agent-chat-isolation-key": "key_A"}) - r = client.delete(f"/responses/{resp['id']}", headers={"x-agent-chat-isolation-key": "key_A"}) + resp = _create_response(client, user_id_key="key_A") + _wait_for_terminal(client, resp["id"], **{"x-agent-user-id": "key_A"}) + r = client.delete(f"/responses/{resp['id']}", headers={"x-agent-user-id": "key_A"}) assert r.status_code == 200 @pytest.mark.asyncio @@ -380,7 +380,7 @@ async def test_delete_mismatched_key_returns_404(self) -> None: "background": True, "stream": True, }, - headers={"x-agent-chat-isolation-key": "key_A"}, + headers={"x-agent-user-id": "key_A"}, ) ) try: @@ -388,7 +388,7 @@ async def test_delete_mismatched_key_returns_404(self) -> None: r = await client.request( "DELETE", f"/responses/{response_id}", - headers={"x-agent-chat-isolation-key": "key_B"}, + headers={"x-agent-user-id": "key_B"}, ) assert r.status_code == 404 finally: @@ -415,7 +415,7 @@ async def test_delete_missing_key_when_created_with_key_returns_404(self) -> Non "background": True, "stream": True, }, - headers={"x-agent-chat-isolation-key": "key_A"}, + headers={"x-agent-user-id": "key_A"}, ) ) try: @@ -435,8 +435,8 @@ async def test_delete_missing_key_when_created_with_key_returns_404(self) -> Non # ── CANCEL with isolation (async — needs real event loop) ── -class TestCancelChatIsolation: - """POST /responses/{id}/cancel with chat isolation key enforcement. +class TestCancelUserIsolation: + """POST /responses/{id}/cancel with user ID enforcement. Cancel tests must use async ASGI client because the handler runs as a background asyncio task that needs the event loop to start before the @@ -459,14 +459,14 @@ async def test_cancel_matching_key_succeeds(self) -> None: "background": True, "stream": True, }, - headers={"x-agent-chat-isolation-key": "key_A"}, + headers={"x-agent-user-id": "key_A"}, ) ) try: await asyncio.wait_for(handler.started.wait(), timeout=5.0) r = await client.post( f"/responses/{response_id}/cancel", - headers={"x-agent-chat-isolation-key": "key_A"}, + headers={"x-agent-user-id": "key_A"}, ) assert r.status_code == 200 finally: @@ -494,14 +494,14 @@ async def test_cancel_mismatched_key_returns_404(self) -> None: "background": True, "stream": True, }, - headers={"x-agent-chat-isolation-key": "key_A"}, + headers={"x-agent-user-id": "key_A"}, ) ) try: await asyncio.wait_for(handler.started.wait(), timeout=5.0) r = await client.post( f"/responses/{response_id}/cancel", - headers={"x-agent-chat-isolation-key": "key_B"}, + headers={"x-agent-user-id": "key_B"}, ) assert r.status_code == 404 finally: @@ -529,7 +529,7 @@ async def test_cancel_missing_key_when_created_with_key_returns_404(self) -> Non "background": True, "stream": True, }, - headers={"x-agent-chat-isolation-key": "key_A"}, + headers={"x-agent-user-id": "key_A"}, ) ) try: @@ -549,8 +549,8 @@ async def test_cancel_missing_key_when_created_with_key_returns_404(self) -> Non # ── INPUT_ITEMS with isolation ──────────────────────────── -class TestInputItemsChatIsolation: - """GET /responses/{id}/input_items with chat isolation key enforcement. +class TestInputItemsUserIsolation: + """GET /responses/{id}/input_items with user ID enforcement. In-flight isolation is enforced locally; after eviction, isolation is enforced by the Foundry storage provider server-side. @@ -558,11 +558,11 @@ class TestInputItemsChatIsolation: def test_input_items_matching_key_returns_200(self) -> None: client = _make_client() - resp = _create_response(client, chat_key="key_A") - _wait_for_terminal(client, resp["id"], **{"x-agent-chat-isolation-key": "key_A"}) + resp = _create_response(client, user_id_key="key_A") + _wait_for_terminal(client, resp["id"], **{"x-agent-user-id": "key_A"}) r = client.get( f"/responses/{resp['id']}/input_items", - headers={"x-agent-chat-isolation-key": "key_A"}, + headers={"x-agent-user-id": "key_A"}, ) assert r.status_code == 200 @@ -581,14 +581,14 @@ async def test_input_items_mismatched_key_returns_404(self) -> None: "background": True, "stream": True, }, - headers={"x-agent-chat-isolation-key": "key_A"}, + headers={"x-agent-user-id": "key_A"}, ) ) try: await asyncio.wait_for(handler.started.wait(), timeout=5.0) r = await client.get( f"/responses/{response_id}/input_items", - headers={"x-agent-chat-isolation-key": "key_B"}, + headers={"x-agent-user-id": "key_B"}, ) assert r.status_code == 404 finally: @@ -615,7 +615,7 @@ async def test_input_items_missing_key_when_created_with_key_returns_404(self) - "background": True, "stream": True, }, - headers={"x-agent-chat-isolation-key": "key_A"}, + headers={"x-agent-user-id": "key_A"}, ) ) try: diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_logging_policy.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_logging_policy.py index 3b507d369e46..ea406d1cb98d 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_logging_policy.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_logging_policy.py @@ -154,63 +154,63 @@ async def test_logging_policy_handles_missing_correlation_headers(caplog: pytest @pytest.mark.asyncio -async def test_logging_policy_logs_isolation_header_presence(caplog: pytest.LogCaptureFixture) -> None: - """Isolation header *presence* is logged but actual values are NOT.""" +async def test_logging_policy_logs_platform_header_presence(caplog: pytest.LogCaptureFixture) -> None: + """Platform header *presence* is logged but actual values are NOT.""" policy = FoundryStorageLoggingPolicy() next_policy = AsyncMock() next_policy.send = AsyncMock(return_value=_make_response(200)) policy.next = next_policy request = _make_request("GET", "https://storage.example.com/responses/r1") - request.http_request.headers["x-agent-user-isolation-key"] = "secret-user-key" - request.http_request.headers["x-agent-chat-isolation-key"] = "secret-chat-key" + request.http_request.headers["x-agent-user-id"] = "secret-user-id" + request.http_request.headers["x-agent-foundry-call-id"] = "secret-call-id" with caplog.at_level(logging.INFO, logger="azure.ai.agentserver"): await policy.send(request) msg = caplog.records[0].message - assert "has_user_isolation_key=True" in msg - assert "has_chat_isolation_key=True" in msg + assert "has_user_id=True" in msg + assert "has_call_id=True" in msg # Values must never appear - assert "secret-user-key" not in msg - assert "secret-chat-key" not in msg + assert "secret-user-id" not in msg + assert "secret-call-id" not in msg @pytest.mark.asyncio -async def test_logging_policy_logs_isolation_header_absence(caplog: pytest.LogCaptureFixture) -> None: - """When isolation headers are absent both flags are False.""" +async def test_logging_policy_logs_platform_header_absence(caplog: pytest.LogCaptureFixture) -> None: + """When platform headers are absent both flags are False.""" policy = FoundryStorageLoggingPolicy() next_policy = AsyncMock() next_policy.send = AsyncMock(return_value=_make_response(200)) policy.next = next_policy request = _make_request("GET", "https://storage.example.com/responses/r1") - # Default _make_request has no isolation headers + # Default _make_request has no platform headers with caplog.at_level(logging.INFO, logger="azure.ai.agentserver"): await policy.send(request) msg = caplog.records[0].message - assert "has_user_isolation_key=False" in msg - assert "has_chat_isolation_key=False" in msg + assert "has_user_id=False" in msg + assert "has_call_id=False" in msg @pytest.mark.asyncio -async def test_logging_policy_transport_failure_omits_isolation_flags(caplog: pytest.LogCaptureFixture) -> None: - """Transport failure ERROR log is minimal and omits isolation flags.""" +async def test_logging_policy_transport_failure_omits_platform_flags(caplog: pytest.LogCaptureFixture) -> None: + """Transport failure ERROR log is minimal and omits platform flags.""" policy = FoundryStorageLoggingPolicy() next_policy = AsyncMock() next_policy.send = AsyncMock(side_effect=ConnectionError("oops")) policy.next = next_policy request = _make_request("POST", "https://storage.example.com/responses") - request.http_request.headers["x-agent-chat-isolation-key"] = "secret" + request.http_request.headers["x-agent-foundry-call-id"] = "secret" with caplog.at_level(logging.DEBUG, logger="azure.ai.agentserver"): with pytest.raises(ConnectionError): await policy.send(request) - # Transport failure log — at ERROR level, does not include isolation flags + # Transport failure log — at ERROR level, does not include platform flags # (transport failure message is intentionally minimal) error_records = [r for r in caplog.records if r.levelno >= logging.ERROR] assert len(error_records) == 1 diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_storage_provider.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_storage_provider.py index b912e146c1e0..ce9f6e20ef87 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_storage_provider.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_storage_provider.py @@ -11,11 +11,11 @@ import pytest from azure.ai.agentserver.core._platform_headers import ( - CHAT_ISOLATION_KEY as _CHAT_ISOLATION_HEADER, - USER_ISOLATION_KEY as _USER_ISOLATION_HEADER, + FOUNDRY_CALL_ID as _CALL_ID_HEADER, + USER_ID as _USER_ID_HEADER, ) -from azure.ai.agentserver.responses._response_context import IsolationContext +from azure.ai.agentserver.responses._response_context import PlatformContext from azure.ai.agentserver.responses.store._foundry_errors import ( FoundryApiError, FoundryBadRequestError, @@ -459,121 +459,121 @@ async def test_get_history_item_ids__omits_optional_params_when_none( # =========================================================================== -# Isolation headers (S-018) +# Platform identity headers (S-018) # =========================================================================== @pytest.mark.asyncio -async def test_create_response__sends_isolation_headers(credential: Any, settings: FoundryStorageSettings) -> None: +async def test_create_response__sends_platform_headers(credential: Any, settings: FoundryStorageSettings) -> None: provider = _make_provider(credential, settings, _make_response(200, {})) from azure.ai.agentserver.responses.models._generated import ResponseObject - isolation = IsolationContext(user_key="u_key_1", chat_key="c_key_1") - await provider.create_response(ResponseObject(_RESPONSE_DICT), None, None, isolation=isolation) + isolation = PlatformContext(user_id_key="u_key_1", call_id="c_key_1") + await provider.create_response(ResponseObject(_RESPONSE_DICT), None, None, context=isolation) request = provider._client.send_request.call_args[0][0] - assert request.headers[_USER_ISOLATION_HEADER] == "u_key_1" - assert request.headers[_CHAT_ISOLATION_HEADER] == "c_key_1" + assert request.headers[_USER_ID_HEADER] == "u_key_1" + assert request.headers[_CALL_ID_HEADER] == "c_key_1" @pytest.mark.asyncio -async def test_get_response__sends_isolation_headers(credential: Any, settings: FoundryStorageSettings) -> None: +async def test_get_response__sends_platform_headers(credential: Any, settings: FoundryStorageSettings) -> None: provider = _make_provider(credential, settings, _make_response(200, _RESPONSE_DICT)) - isolation = IsolationContext(user_key="u_key_2", chat_key="c_key_2") - await provider.get_response("resp_abc123", isolation=isolation) + isolation = PlatformContext(user_id_key="u_key_2", call_id="c_key_2") + await provider.get_response("resp_abc123", context=isolation) request = provider._client.send_request.call_args[0][0] - assert request.headers[_USER_ISOLATION_HEADER] == "u_key_2" - assert request.headers[_CHAT_ISOLATION_HEADER] == "c_key_2" + assert request.headers[_USER_ID_HEADER] == "u_key_2" + assert request.headers[_CALL_ID_HEADER] == "c_key_2" @pytest.mark.asyncio -async def test_update_response__sends_isolation_headers(credential: Any, settings: FoundryStorageSettings) -> None: +async def test_update_response__sends_platform_headers(credential: Any, settings: FoundryStorageSettings) -> None: provider = _make_provider(credential, settings, _make_response(200, {})) from azure.ai.agentserver.responses.models._generated import ResponseObject - isolation = IsolationContext(user_key="u_key_3", chat_key="c_key_3") - await provider.update_response(ResponseObject(_RESPONSE_DICT), isolation=isolation) + isolation = PlatformContext(user_id_key="u_key_3", call_id="c_key_3") + await provider.update_response(ResponseObject(_RESPONSE_DICT), context=isolation) request = provider._client.send_request.call_args[0][0] - assert request.headers[_USER_ISOLATION_HEADER] == "u_key_3" - assert request.headers[_CHAT_ISOLATION_HEADER] == "c_key_3" + assert request.headers[_USER_ID_HEADER] == "u_key_3" + assert request.headers[_CALL_ID_HEADER] == "c_key_3" @pytest.mark.asyncio -async def test_delete_response__sends_isolation_headers(credential: Any, settings: FoundryStorageSettings) -> None: +async def test_delete_response__sends_platform_headers(credential: Any, settings: FoundryStorageSettings) -> None: provider = _make_provider(credential, settings, _make_response(200, {})) - isolation = IsolationContext(user_key="u_key_4", chat_key="c_key_4") - await provider.delete_response("resp_abc123", isolation=isolation) + isolation = PlatformContext(user_id_key="u_key_4", call_id="c_key_4") + await provider.delete_response("resp_abc123", context=isolation) request = provider._client.send_request.call_args[0][0] - assert request.headers[_USER_ISOLATION_HEADER] == "u_key_4" - assert request.headers[_CHAT_ISOLATION_HEADER] == "c_key_4" + assert request.headers[_USER_ID_HEADER] == "u_key_4" + assert request.headers[_CALL_ID_HEADER] == "c_key_4" @pytest.mark.asyncio -async def test_get_input_items__sends_isolation_headers(credential: Any, settings: FoundryStorageSettings) -> None: +async def test_get_input_items__sends_platform_headers(credential: Any, settings: FoundryStorageSettings) -> None: provider = _make_provider(credential, settings, _make_response(200, {"data": []})) - isolation = IsolationContext(user_key="u_key_5", chat_key="c_key_5") - await provider.get_input_items("resp_abc123", isolation=isolation) + isolation = PlatformContext(user_id_key="u_key_5", call_id="c_key_5") + await provider.get_input_items("resp_abc123", context=isolation) request = provider._client.send_request.call_args[0][0] - assert request.headers[_USER_ISOLATION_HEADER] == "u_key_5" - assert request.headers[_CHAT_ISOLATION_HEADER] == "c_key_5" + assert request.headers[_USER_ID_HEADER] == "u_key_5" + assert request.headers[_CALL_ID_HEADER] == "c_key_5" @pytest.mark.asyncio -async def test_get_items__sends_isolation_headers(credential: Any, settings: FoundryStorageSettings) -> None: +async def test_get_items__sends_platform_headers(credential: Any, settings: FoundryStorageSettings) -> None: provider = _make_provider(credential, settings, _make_response(200, [_OUTPUT_ITEM_DICT])) - isolation = IsolationContext(user_key="u_key_6", chat_key="c_key_6") - await provider.get_items(["item_out_001"], isolation=isolation) + isolation = PlatformContext(user_id_key="u_key_6", call_id="c_key_6") + await provider.get_items(["item_out_001"], context=isolation) request = provider._client.send_request.call_args[0][0] - assert request.headers[_USER_ISOLATION_HEADER] == "u_key_6" - assert request.headers[_CHAT_ISOLATION_HEADER] == "c_key_6" + assert request.headers[_USER_ID_HEADER] == "u_key_6" + assert request.headers[_CALL_ID_HEADER] == "c_key_6" @pytest.mark.asyncio -async def test_get_history_item_ids__sends_isolation_headers(credential: Any, settings: FoundryStorageSettings) -> None: +async def test_get_history_item_ids__sends_platform_headers(credential: Any, settings: FoundryStorageSettings) -> None: provider = _make_provider(credential, settings, _make_response(200, [])) - isolation = IsolationContext(user_key="u_key_7", chat_key="c_key_7") - await provider.get_history_item_ids(None, None, limit=10, isolation=isolation) + isolation = PlatformContext(user_id_key="u_key_7", call_id="c_key_7") + await provider.get_history_item_ids(None, None, limit=10, context=isolation) request = provider._client.send_request.call_args[0][0] - assert request.headers[_USER_ISOLATION_HEADER] == "u_key_7" - assert request.headers[_CHAT_ISOLATION_HEADER] == "c_key_7" + assert request.headers[_USER_ID_HEADER] == "u_key_7" + assert request.headers[_CALL_ID_HEADER] == "c_key_7" @pytest.mark.asyncio -async def test_isolation_headers__omitted_when_none(credential: Any, settings: FoundryStorageSettings) -> None: - """When isolation=None (default), no isolation headers are sent.""" +async def test_platform_headers__omitted_when_none(credential: Any, settings: FoundryStorageSettings) -> None: + """When context=None (default), no platform headers are sent.""" provider = _make_provider(credential, settings, _make_response(200, _RESPONSE_DICT)) await provider.get_response("resp_abc123") request = provider._client.send_request.call_args[0][0] - assert _USER_ISOLATION_HEADER not in request.headers - assert _CHAT_ISOLATION_HEADER not in request.headers + assert _USER_ID_HEADER not in request.headers + assert _CALL_ID_HEADER not in request.headers @pytest.mark.asyncio -async def test_isolation_headers__partial_keys_only_sends_present( +async def test_platform_headers__partial_keys_only_sends_present( credential: Any, settings: FoundryStorageSettings ) -> None: - """When only user_key is set, only user header is added.""" + """When only user_id_key is set, only the user header is added.""" provider = _make_provider(credential, settings, _make_response(200, _RESPONSE_DICT)) - isolation = IsolationContext(user_key="u_only") - await provider.get_response("resp_abc123", isolation=isolation) + isolation = PlatformContext(user_id_key="u_only") + await provider.get_response("resp_abc123", context=isolation) request = provider._client.send_request.call_args[0][0] - assert request.headers[_USER_ISOLATION_HEADER] == "u_only" - assert _CHAT_ISOLATION_HEADER not in request.headers + assert request.headers[_USER_ID_HEADER] == "u_only" + assert _CALL_ID_HEADER not in request.headers # =========================================================================== diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_in_memory_provider_crud.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_in_memory_provider_crud.py index d90dff957de9..3aa249cc0675 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_in_memory_provider_crud.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_in_memory_provider_crud.py @@ -4,7 +4,7 @@ Covers create, read, update, delete of response envelopes, output item storage, history resolution via previous_response_id -and conversation_id, and defensive-copy isolation. +and conversation_id, and defensive-copy platform context. """ from __future__ import annotations diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_platform_headers.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_platform_headers.py index c3721fc058b9..350ac5d0f7d9 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_platform_headers.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_platform_headers.py @@ -6,14 +6,14 @@ from azure.ai.agentserver.core._platform_headers import ( APIM_REQUEST_ID, - CHAT_ISOLATION_KEY, CLIENT_HEADER_PREFIX, CLIENT_REQUEST_ID, + FOUNDRY_CALL_ID, REQUEST_ID, SERVER_VERSION, SESSION_ID, TRACEPARENT, - USER_ISOLATION_KEY, + USER_ID, ) from azure.ai.agentserver.core._request_id import REQUEST_ID_STATE_KEY @@ -30,11 +30,11 @@ def test_server_version(self) -> None: def test_session_id(self) -> None: assert SESSION_ID == "x-agent-session-id" - def test_user_isolation_key(self) -> None: - assert USER_ISOLATION_KEY == "x-agent-user-isolation-key" + def test_user_id(self) -> None: + assert USER_ID == "x-agent-user-id" - def test_chat_isolation_key(self) -> None: - assert CHAT_ISOLATION_KEY == "x-agent-chat-isolation-key" + def test_foundry_call_id(self) -> None: + assert FOUNDRY_CALL_ID == "x-agent-foundry-call-id" def test_client_header_prefix(self) -> None: assert CLIENT_HEADER_PREFIX == "x-client-" diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_response_context_input_items.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_response_context_input_items.py index 5f10a2906adf..bc878b06b8b7 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_response_context_input_items.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_response_context_input_items.py @@ -9,7 +9,7 @@ import pytest -from azure.ai.agentserver.responses._response_context import IsolationContext, ResponseContext +from azure.ai.agentserver.responses._response_context import PlatformContext, ResponseContext from azure.ai.agentserver.responses.models._generated import ( CreateResponse, Item, @@ -91,7 +91,7 @@ async def test_get_input_items__resolves_single_reference() -> None: # Resolved via to_item(): OutputItemMessage → ItemMessage assert isinstance(items[0], ItemMessage) assert items[0].role == "assistant" - provider.get_items.assert_awaited_once_with(["item_abc"], isolation=ctx.isolation) + provider.get_items.assert_awaited_once_with(["item_abc"], context=ctx.platform_context) # ------------------------------------------------------------------ @@ -258,17 +258,17 @@ async def test_get_input_items__empty_input() -> None: # ------------------------------------------------------------------ -# Isolation context is forwarded to provider +# Platform context is forwarded to provider # ------------------------------------------------------------------ @pytest.mark.asyncio -async def test_get_input_items__forwards_isolation() -> None: - """Isolation context is passed through to provider.get_items().""" +async def test_get_input_items__forwards_platform_context() -> None: + """Platform context is passed through to provider.get_items().""" ref = ItemReferenceParam(id="item_iso") resolved = OutputItemMessage(id="item_iso", role="assistant", content=[], status="completed") provider = _mock_provider(get_items_return=[resolved]) - isolation = IsolationContext(user_key="user_123", chat_key="chat_456") + isolation = PlatformContext(user_id_key="user_123", call_id="call_456") request = _make_request([ref]) ctx = ResponseContext( @@ -277,14 +277,14 @@ async def test_get_input_items__forwards_isolation() -> None: request=request, input_items=[ref], provider=provider, - isolation=isolation, + platform_context=isolation, ) items = await ctx.get_input_items() assert len(items) == 1 assert isinstance(items[0], ItemMessage) # resolved via to_item() - provider.get_items.assert_awaited_once_with(["item_iso"], isolation=isolation) + provider.get_items.assert_awaited_once_with(["item_iso"], context=isolation) # ------------------------------------------------------------------ From 608a797d3b5f19fa13dab7f49b88daab984bd2a8 Mon Sep 17 00:00:00 2001 From: Pranav Pandit Date: Sat, 20 Jun 2026 12:40:51 +0000 Subject: [PATCH 02/11] [agentserver] Bump core dependency floor to 2.0.0b7 responses and invocations import RequestContext / get_request_context from azure-ai-agentserver-core, which were introduced in core 2.0.0b7. The previous floor (>=2.0.0b4) let isolated build/doc venvs resolve an older published core lacking those symbols, breaking the Sphinx import. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/agentserver/azure-ai-agentserver-invocations/pyproject.toml | 2 +- sdk/agentserver/azure-ai-agentserver-responses/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/pyproject.toml b/sdk/agentserver/azure-ai-agentserver-invocations/pyproject.toml index b70d8ea30022..af34178271a0 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/pyproject.toml +++ b/sdk/agentserver/azure-ai-agentserver-invocations/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ keywords = ["azure", "azure sdk", "agent", "agentserver", "invocations"] dependencies = [ - "azure-ai-agentserver-core>=2.0.0b4", + "azure-ai-agentserver-core>=2.0.0b7", ] [dependency-groups] diff --git a/sdk/agentserver/azure-ai-agentserver-responses/pyproject.toml b/sdk/agentserver/azure-ai-agentserver-responses/pyproject.toml index 2e51d7728bfd..2f11803ab67b 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/pyproject.toml +++ b/sdk/agentserver/azure-ai-agentserver-responses/pyproject.toml @@ -18,7 +18,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "azure-ai-agentserver-core>=2.0.0b4", + "azure-ai-agentserver-core>=2.0.0b7", "azure-core>=1.30.0", "isodate>=0.6.1", "aiohttp>=3.10.0,<4.0.0", From 0949b195eac22636d8c7bdebf2cb40ed3d3b332a Mon Sep 17 00:00:00 2001 From: Pranav Pandit Date: Sat, 20 Jun 2026 12:48:07 +0000 Subject: [PATCH 03/11] [agentserver] Forward only call ID (not user-id) on outbound 1P calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: 1P services accept/trust only x-agent-foundry-call-id on inbound. x-agent-user-id is not accepted/trusted by 1P, so it must not be forwarded outbound — it remains an inbound-only header used for container-side per-user state partitioning. - RequestContext.platform_headers() now emits only x-agent-foundry-call-id. - Foundry Storage provider forwards only the call ID. - Storage logging policy logs only call-id presence. - ghcopilot toolbox bridge forwards only the call ID (via platform_headers()). - Updated tests, CHANGELOGs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure-ai-agentserver-core/CHANGELOG.md | 2 +- .../ai/agentserver/core/_request_context.py | 14 ++++---- .../tests/test_request_context.py | 10 +++--- .../CHANGELOG.md | 2 +- .../tests/unit_tests/test_toolbox.py | 5 +-- .../CHANGELOG.md | 2 +- .../CHANGELOG.md | 2 +- .../store/_foundry_logging_policy.py | 5 +-- .../responses/store/_foundry_provider.py | 16 ++++----- .../tests/unit/test_foundry_logging_policy.py | 4 --- .../unit/test_foundry_storage_provider.py | 35 +++++++++++++------ 11 files changed, 55 insertions(+), 42 deletions(-) diff --git a/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md index 9bef39c63eb6..0376f3938bc6 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md @@ -6,7 +6,7 @@ - Container protocol version `2.0.0` support: added the platform identity header constants `x-agent-user-id` (`USER_ID`) — the global, cross-agent per-user partition key — and `x-agent-foundry-call-id` (`FOUNDRY_CALL_ID`) — the opaque per-request call identifier — to the `_platform_headers` module. - Added `FOUNDRY_AGENT_ID` environment variable support exposing the agent's stable GUID via `AgentConfig.agent_guid` and the `resolve_agent_guid()` helper. -- Added a request-scoped platform context: `RequestContext`, `get_request_context()`, `set_request_context()`, and `reset_request_context()`. Protocol packages bind the inbound per-request call ID and user ID so that handler code (and the SDK HTTP pipeline) can forward them on outbound Foundry platform calls. `RequestContext.platform_headers()` builds the headers to forward. +- Added a request-scoped platform context: `RequestContext`, `get_request_context()`, `set_request_context()`, and `reset_request_context()`. Protocol packages bind the inbound per-request call ID and user ID so that handler code (and the SDK HTTP pipeline) can read them. `RequestContext.platform_headers()` builds the headers to forward on outbound Foundry 1P calls — the per-request call ID only; `x-agent-user-id` is **not** forwarded (it is not accepted/trusted by 1P services and is used only for container-side state partitioning). ### Breaking Changes diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_request_context.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_request_context.py index aa5d5c2a35cd..f233a2eca844 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_request_context.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_request_context.py @@ -28,7 +28,7 @@ from contextvars import ContextVar, Token -from ._platform_headers import FOUNDRY_CALL_ID, USER_ID +from ._platform_headers import FOUNDRY_CALL_ID __all__ = [ "RequestContext", @@ -71,17 +71,19 @@ def __init__( def platform_headers(self) -> dict[str, str]: """Build the platform identity headers to forward on outbound 1P calls. - Only headers whose source value is present (non-``None``) are included. - The managed-identity ``Authorization`` header is unaffected — these are - **additional** headers for caller-identity context and data partitioning. + Only ``x-agent-foundry-call-id`` is forwarded: 1P services resolve the + caller context server-side from the opaque call ID. ``x-agent-user-id`` + is **not** forwarded — it is not accepted/trusted by 1P services and is + only consumed container-side for per-user state partitioning. The call ID + is included only when present (non-``None``). The managed-identity + ``Authorization`` header is unaffected — this is an **additional** header + for caller-identity context. :return: A mapping of header name to value, suitable for merging into an outbound request's headers. :rtype: dict[str, str] """ headers: dict[str, str] = {} - if self.user_id is not None: - headers[USER_ID] = self.user_id if self.call_id is not None: headers[FOUNDRY_CALL_ID] = self.call_id return headers diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/test_request_context.py b/sdk/agentserver/azure-ai-agentserver-core/tests/test_request_context.py index 740a1ac82d91..df771e23dc6e 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/test_request_context.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/test_request_context.py @@ -35,15 +35,17 @@ def test_set_and_reset(self) -> None: reset_request_context(token) assert get_request_context().call_id is None - def test_platform_headers_includes_present_values(self) -> None: + def test_platform_headers_includes_call_id(self) -> None: ctx = RequestContext(call_id="cid", user_id="uid") headers = ctx.platform_headers() - assert headers[USER_ID] == "uid" - assert headers[FOUNDRY_CALL_ID] == "cid" + # Only the call ID is forwarded to 1P services; user_id is not. + assert headers == {FOUNDRY_CALL_ID: "cid"} + assert USER_ID not in headers def test_platform_headers_omits_absent_values(self) -> None: assert RequestContext().platform_headers() == {} - assert RequestContext(user_id="uid").platform_headers() == {USER_ID: "uid"} + # user_id alone never produces an outbound header. + assert RequestContext(user_id="uid").platform_headers() == {} assert RequestContext(call_id="cid").platform_headers() == {FOUNDRY_CALL_ID: "cid"} def test_context_propagates_into_child_task(self) -> None: diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md index 23d7275cfac0..cccd48848e88 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features Added -- Container protocol version `2.0.0` support: the toolbox MCP bridge now forwards the platform identity headers (`x-agent-foundry-call-id` and `x-agent-user-id`) from the inbound request context on all outbound Foundry toolbox calls (initialize, `tools/list`, `tools/call`). No-op under protocol version `1.0.0` or local development. +- Container protocol version `2.0.0` support: the toolbox MCP bridge now forwards the per-request call ID (`x-agent-foundry-call-id`) from the inbound request context on all outbound Foundry toolbox calls (initialize, `tools/list`, `tools/call`). No-op under protocol version `1.0.0` or local development. ## 1.0.0b2 (2026-04-24) diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py b/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py index 1e516528e5d2..ee0541c439f0 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py @@ -349,7 +349,7 @@ def test_default_schema_when_missing(self): class TestMcpBridgePlatformHeaders: """The MCP bridge forwards the inbound call ID / user ID on outbound calls.""" - def test_request_headers_forward_platform_context(self): + def test_request_headers_forward_call_id(self): from azure.ai.agentserver.core import ( RequestContext, reset_request_context, @@ -363,8 +363,9 @@ def test_request_headers_forward_platform_context(self): finally: reset_request_context(token) + # Only the call ID is forwarded to 1P; user_id is not accepted/trusted by 1P. assert headers["x-agent-foundry-call-id"] == "call-123" - assert headers["x-agent-user-id"] == "user-abc" + assert "x-agent-user-id" not in headers assert headers["X-Static"] == "1" def test_request_headers_omit_platform_context_when_absent(self): diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-invocations/CHANGELOG.md index 7f295ab4a188..9106cf7fa345 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-invocations/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features Added -- Container protocol version `2.0.0` support: reads `x-agent-user-id` and `x-agent-foundry-call-id` from inbound requests and binds them to the request-scoped platform context so they are forwarded on outbound Foundry platform calls. The values are also exposed on `request.state.user_id` and `request.state.call_id`. +- Container protocol version `2.0.0` support: reads `x-agent-user-id` and `x-agent-foundry-call-id` from inbound requests and binds them to the request-scoped platform context so the per-request call ID is forwarded on outbound Foundry 1P calls (`x-agent-user-id` is not forwarded to 1P). The values are also exposed on `request.state.user_id` and `request.state.call_id`. ### Breaking Changes diff --git a/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md index 7ff0787306cc..cbbcdf06c35e 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features Added -- Container protocol version `2.0.0` support: the per-request call ID (`x-agent-foundry-call-id`) and global user ID (`x-agent-user-id`) are read from inbound requests, exposed on `ResponseContext.platform_context`, and forwarded as headers on all outbound Foundry Storage calls. The call ID is also bound to the request-scoped platform context so handler/tool code making raw outbound calls can forward it. +- Container protocol version `2.0.0` support: the per-request call ID (`x-agent-foundry-call-id`) and global user ID (`x-agent-user-id`) are read from inbound requests and exposed on `ResponseContext.platform_context`. The per-request call ID is forwarded on all outbound Foundry Storage calls and bound to the request-scoped platform context so handler/tool code making raw outbound calls can forward it; `x-agent-user-id` is used only for container-side partitioning and is not forwarded to 1P services. ### Breaking Changes diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_logging_policy.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_logging_policy.py index fc112c851561..06ac956eb451 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_logging_policy.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_logging_policy.py @@ -21,7 +21,6 @@ FOUNDRY_CALL_ID, REQUEST_ID, TRACEPARENT, - USER_ID, ) from azure.core.pipeline import PipelineRequest, PipelineResponse from azure.core.pipeline.policies import AsyncHTTPPolicy @@ -95,7 +94,6 @@ async def send(self, request: PipelineRequest) -> PipelineResponse: client_request_id = http_request.headers.get(CLIENT_REQUEST_ID, "") traceparent = http_request.headers.get(TRACEPARENT, "") - has_user_id = USER_ID in http_request.headers has_call_id = FOUNDRY_CALL_ID in http_request.headers logger.debug( @@ -136,7 +134,7 @@ async def send(self, request: PipelineRequest) -> PipelineResponse: "Foundry storage %s %s -> %d (%.1fms, " "x-ms-client-request-id=%s, traceparent=%s, " "x-request-id=%s, apim-request-id=%s, " - "has_user_id=%s, has_call_id=%s)", + "has_call_id=%s)", method, url, status_code, @@ -145,7 +143,6 @@ async def send(self, request: PipelineRequest) -> PipelineResponse: traceparent, x_request_id, apim_request_id, - has_user_id, has_call_id, ) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py index 70f173a986bf..5ef23ccc9630 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterable from urllib.parse import quote as _url_quote -from azure.ai.agentserver.core._platform_headers import FOUNDRY_CALL_ID, PLATFORM_ERROR_TAG, USER_ID # pylint: disable=import-error,no-name-in-module +from azure.ai.agentserver.core._platform_headers import FOUNDRY_CALL_ID, PLATFORM_ERROR_TAG # pylint: disable=import-error,no-name-in-module from azure.core import AsyncPipelineClient from azure.core.credentials_async import AsyncTokenCredential from azure.core.exceptions import ServiceRequestError, ServiceResponseError @@ -69,21 +69,21 @@ def _encode(value: str) -> str: def _apply_platform_headers(request: HttpRequest, context: PlatformContext | None) -> None: - """Forward platform identity headers on an outbound HTTP request when present. + """Forward the per-request call ID on an outbound HTTP request when present. - On protocol version ``2.0.0`` the container is responsible for forwarding the - per-request call ID (``x-agent-foundry-call-id``) and the global user ID - (``x-agent-user-id``) on all outbound calls to Foundry platform services. + On protocol version ``2.0.0`` the container forwards the per-request call ID + (``x-agent-foundry-call-id``) on outbound calls to Foundry platform services; + the storage service resolves the caller context server-side from it. The + ``x-agent-user-id`` header is **not** forwarded — it is not accepted/trusted + by 1P services and is used only for container-side state partitioning. :param request: The outbound HTTP request to modify. :type request: ~azure.core.rest.HttpRequest - :param context: Platform context containing the user ID / call ID, or ``None``. + :param context: Platform context containing the call ID, or ``None``. :type context: ~azure.ai.agentserver.responses.PlatformContext | None """ if context is None: return - if context.user_id_key is not None: - request.headers[USER_ID] = context.user_id_key if context.call_id is not None: request.headers[FOUNDRY_CALL_ID] = context.call_id diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_logging_policy.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_logging_policy.py index ea406d1cb98d..6b33253b7d24 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_logging_policy.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_logging_policy.py @@ -162,17 +162,14 @@ async def test_logging_policy_logs_platform_header_presence(caplog: pytest.LogCa policy.next = next_policy request = _make_request("GET", "https://storage.example.com/responses/r1") - request.http_request.headers["x-agent-user-id"] = "secret-user-id" request.http_request.headers["x-agent-foundry-call-id"] = "secret-call-id" with caplog.at_level(logging.INFO, logger="azure.ai.agentserver"): await policy.send(request) msg = caplog.records[0].message - assert "has_user_id=True" in msg assert "has_call_id=True" in msg # Values must never appear - assert "secret-user-id" not in msg assert "secret-call-id" not in msg @@ -191,7 +188,6 @@ async def test_logging_policy_logs_platform_header_absence(caplog: pytest.LogCap await policy.send(request) msg = caplog.records[0].message - assert "has_user_id=False" in msg assert "has_call_id=False" in msg diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_storage_provider.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_storage_provider.py index ce9f6e20ef87..0ef99fb9b2b5 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_storage_provider.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_storage_provider.py @@ -472,7 +472,7 @@ async def test_create_response__sends_platform_headers(credential: Any, settings await provider.create_response(ResponseObject(_RESPONSE_DICT), None, None, context=isolation) request = provider._client.send_request.call_args[0][0] - assert request.headers[_USER_ID_HEADER] == "u_key_1" + assert _USER_ID_HEADER not in request.headers # user_id is not forwarded to 1P assert request.headers[_CALL_ID_HEADER] == "c_key_1" @@ -484,7 +484,7 @@ async def test_get_response__sends_platform_headers(credential: Any, settings: F await provider.get_response("resp_abc123", context=isolation) request = provider._client.send_request.call_args[0][0] - assert request.headers[_USER_ID_HEADER] == "u_key_2" + assert _USER_ID_HEADER not in request.headers # user_id is not forwarded to 1P assert request.headers[_CALL_ID_HEADER] == "c_key_2" @@ -497,7 +497,7 @@ async def test_update_response__sends_platform_headers(credential: Any, settings await provider.update_response(ResponseObject(_RESPONSE_DICT), context=isolation) request = provider._client.send_request.call_args[0][0] - assert request.headers[_USER_ID_HEADER] == "u_key_3" + assert _USER_ID_HEADER not in request.headers # user_id is not forwarded to 1P assert request.headers[_CALL_ID_HEADER] == "c_key_3" @@ -509,7 +509,7 @@ async def test_delete_response__sends_platform_headers(credential: Any, settings await provider.delete_response("resp_abc123", context=isolation) request = provider._client.send_request.call_args[0][0] - assert request.headers[_USER_ID_HEADER] == "u_key_4" + assert _USER_ID_HEADER not in request.headers # user_id is not forwarded to 1P assert request.headers[_CALL_ID_HEADER] == "c_key_4" @@ -521,7 +521,7 @@ async def test_get_input_items__sends_platform_headers(credential: Any, settings await provider.get_input_items("resp_abc123", context=isolation) request = provider._client.send_request.call_args[0][0] - assert request.headers[_USER_ID_HEADER] == "u_key_5" + assert _USER_ID_HEADER not in request.headers # user_id is not forwarded to 1P assert request.headers[_CALL_ID_HEADER] == "c_key_5" @@ -533,7 +533,7 @@ async def test_get_items__sends_platform_headers(credential: Any, settings: Foun await provider.get_items(["item_out_001"], context=isolation) request = provider._client.send_request.call_args[0][0] - assert request.headers[_USER_ID_HEADER] == "u_key_6" + assert _USER_ID_HEADER not in request.headers # user_id is not forwarded to 1P assert request.headers[_CALL_ID_HEADER] == "c_key_6" @@ -545,7 +545,7 @@ async def test_get_history_item_ids__sends_platform_headers(credential: Any, set await provider.get_history_item_ids(None, None, limit=10, context=isolation) request = provider._client.send_request.call_args[0][0] - assert request.headers[_USER_ID_HEADER] == "u_key_7" + assert _USER_ID_HEADER not in request.headers # user_id is not forwarded to 1P assert request.headers[_CALL_ID_HEADER] == "c_key_7" @@ -562,20 +562,35 @@ async def test_platform_headers__omitted_when_none(credential: Any, settings: Fo @pytest.mark.asyncio -async def test_platform_headers__partial_keys_only_sends_present( +async def test_platform_headers__user_id_alone_sends_nothing( credential: Any, settings: FoundryStorageSettings ) -> None: - """When only user_id_key is set, only the user header is added.""" + """user_id is never forwarded to 1P; with only user_id_key set, no headers are sent.""" provider = _make_provider(credential, settings, _make_response(200, _RESPONSE_DICT)) isolation = PlatformContext(user_id_key="u_only") await provider.get_response("resp_abc123", context=isolation) request = provider._client.send_request.call_args[0][0] - assert request.headers[_USER_ID_HEADER] == "u_only" + assert _USER_ID_HEADER not in request.headers assert _CALL_ID_HEADER not in request.headers +@pytest.mark.asyncio +async def test_platform_headers__call_id_alone_sends_call_id( + credential: Any, settings: FoundryStorageSettings +) -> None: + """With only call_id set, only the call-id header is forwarded.""" + provider = _make_provider(credential, settings, _make_response(200, _RESPONSE_DICT)) + + isolation = PlatformContext(call_id="c_only") + await provider.get_response("resp_abc123", context=isolation) + + request = provider._client.send_request.call_args[0][0] + assert request.headers[_CALL_ID_HEADER] == "c_only" + assert _USER_ID_HEADER not in request.headers + + # =========================================================================== # Error mapping # =========================================================================== From c483f98143894e0066d729881660dea270611b70 Mon Sep 17 00:00:00 2001 From: Pranav Pandit Date: Sat, 20 Jun 2026 13:23:18 +0000 Subject: [PATCH 04/11] [agentserver] Fix spell-check: rename pctx_token -> platform_ctx_token Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ai/agentserver/responses/hosting/_endpoint_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py index ceeb821941b4..1effa392f480 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py @@ -661,7 +661,7 @@ async def handle_create(self, request: Request) -> Response: # pylint: disable= str_token = _streaming_var.set(str(ctx.stream).lower()) # Bind platform context so handler/tool code making raw outbound 1P calls # can forward the per-request call ID and user ID (protocol 2.0.0). - pctx_token = set_request_context( + platform_ctx_token = set_request_context( RequestContext( call_id=ctx.call_id or None, user_id=ctx.user_id or None, @@ -766,7 +766,7 @@ async def _iter_with_cleanup(): # type: ignore[return] _response_id_var.reset(rid_token) _conversation_id_var.reset(cid_token) _streaming_var.reset(str_token) - reset_request_context(pctx_token) + reset_request_context(platform_ctx_token) # Flush pending spans before the response is sent. # BatchSpanProcessor exports on a timer; in hosted sandboxes # the platform may freeze the process after the HTTP response, From 1ca827362dccd816a786ca4f2896b204103d421d Mon Sep 17 00:00:00 2001 From: Pranav Pandit Date: Sun, 21 Jun 2026 00:03:13 +0000 Subject: [PATCH 05/11] =?UTF-8?q?[agentserver]=20ghcopilot=20toolbox=20cal?= =?UTF-8?q?l-id=20(=C2=A78)=20+=20rename=20RequestContext=20->=20FoundryAg?= =?UTF-8?q?entRequestContext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ghcopilot: echo the per-request x-agent-foundry-call-id on outbound toolbox tools/call. Because the Copilot engine dispatches tools on a separate reader task where the request context var is empty, the per-turn call id is captured in the request task and resolved out-of-band keyed by the Copilot session_id, then stamped as both the HTTP header and params._meta. Copilot sessions are bound to a single user (x-agent-user-id) and never reused across users. - Rename the ambient request context type RequestContext -> FoundryAgentRequestContext (get_request_context() unchanged) to avoid the generic name and to match the .NET type; Azure.RequestContext exists in Azure.Core on the .NET side. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure-ai-agentserver-core/CHANGELOG.md | 2 +- .../azure/ai/agentserver/core/__init__.py | 6 +- .../ai/agentserver/core/_request_context.py | 20 ++--- .../tests/test_request_context.py | 18 ++--- .../CHANGELOG.md | 2 +- .../githubcopilot/_copilot_adapter.py | 45 ++++++++++- .../ai/agentserver/githubcopilot/_toolbox.py | 68 ++++++++++++++++- .../tests/unit_tests/test_toolbox.py | 76 ++++++++++++++++++- .../ai/agentserver/invocations/_invocation.py | 8 +- .../responses/hosting/_endpoint_handler.py | 4 +- 10 files changed, 210 insertions(+), 39 deletions(-) diff --git a/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md index 0376f3938bc6..1ba490f59cb0 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md @@ -6,7 +6,7 @@ - Container protocol version `2.0.0` support: added the platform identity header constants `x-agent-user-id` (`USER_ID`) — the global, cross-agent per-user partition key — and `x-agent-foundry-call-id` (`FOUNDRY_CALL_ID`) — the opaque per-request call identifier — to the `_platform_headers` module. - Added `FOUNDRY_AGENT_ID` environment variable support exposing the agent's stable GUID via `AgentConfig.agent_guid` and the `resolve_agent_guid()` helper. -- Added a request-scoped platform context: `RequestContext`, `get_request_context()`, `set_request_context()`, and `reset_request_context()`. Protocol packages bind the inbound per-request call ID and user ID so that handler code (and the SDK HTTP pipeline) can read them. `RequestContext.platform_headers()` builds the headers to forward on outbound Foundry 1P calls — the per-request call ID only; `x-agent-user-id` is **not** forwarded (it is not accepted/trusted by 1P services and is used only for container-side state partitioning). +- Added a request-scoped platform context: `FoundryAgentRequestContext`, `get_request_context()`, `set_request_context()`, and `reset_request_context()`. Protocol packages bind the inbound per-request call ID and user ID so that handler code (and the SDK HTTP pipeline) can read them. `RequestContext.platform_headers()` builds the headers to forward on outbound Foundry 1P calls — the per-request call ID only; `x-agent-user-id` is **not** forwarded (it is not accepted/trusted by 1P services and is used only for container-side state partitioning). ### Breaking Changes diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py index 4ba3ca42b4b1..1c08b10f13c6 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/__init__.py @@ -11,7 +11,7 @@ from azure.ai.agentserver.core import ( AgentConfig, AgentServerHost, - RequestContext, + FoundryAgentRequestContext, configure_observability, create_error_response, detach_context, @@ -30,7 +30,7 @@ from ._errors import create_error_response from ._middleware import InboundRequestLoggingMiddleware from ._request_context import ( - RequestContext, + FoundryAgentRequestContext, get_request_context, reset_request_context, set_request_context, @@ -52,7 +52,7 @@ "AgentConfig", "AgentServerHost", "InboundRequestLoggingMiddleware", - "RequestContext", + "FoundryAgentRequestContext", "RequestIdMiddleware", "build_server_version", "configure_observability", diff --git a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_request_context.py b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_request_context.py index f233a2eca844..545360b39911 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_request_context.py +++ b/sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_request_context.py @@ -31,14 +31,14 @@ from ._platform_headers import FOUNDRY_CALL_ID __all__ = [ - "RequestContext", + "FoundryAgentRequestContext", "get_request_context", "set_request_context", "reset_request_context", ] -class RequestContext: +class FoundryAgentRequestContext: """Platform-supplied, request-scoped identity context. Populated by the protocol endpoint from inbound platform headers on every @@ -89,25 +89,25 @@ def platform_headers(self) -> dict[str, str]: return headers -_EMPTY = RequestContext() +_EMPTY = FoundryAgentRequestContext() -_request_context_var: ContextVar[RequestContext] = ContextVar("agentserver_request_context") +_request_context_var: ContextVar[FoundryAgentRequestContext] = ContextVar("agentserver_request_context") -def get_request_context() -> RequestContext: +def get_request_context() -> FoundryAgentRequestContext: """Return the platform context for the current request. Safe to call from anywhere during request processing. When no context has been established (e.g. outside a request, or local development), an empty - :class:`RequestContext` with all-``None`` fields is returned. + :class:`FoundryAgentRequestContext` with all-``None`` fields is returned. :return: The current request-scoped platform context. - :rtype: RequestContext + :rtype: FoundryAgentRequestContext """ return _request_context_var.get(_EMPTY) -def set_request_context(context: RequestContext) -> Token[RequestContext]: +def set_request_context(context: FoundryAgentRequestContext) -> Token[FoundryAgentRequestContext]: """Bind ``context`` as the current request context (internal). Called by protocol endpoints at the start of request handling. The returned @@ -115,14 +115,14 @@ def set_request_context(context: RequestContext) -> Token[RequestContext]: completes to avoid leaking context across requests on the same task. :param context: The request context to bind. - :type context: RequestContext + :type context: FoundryAgentRequestContext :return: A reset token for restoring the previous value. :rtype: ~contextvars.Token """ return _request_context_var.set(context) -def reset_request_context(token: Token[RequestContext]) -> None: +def reset_request_context(token: Token[FoundryAgentRequestContext]) -> None: """Restore the request context to its previous value (internal). :param token: The token returned by :func:`set_request_context`. diff --git a/sdk/agentserver/azure-ai-agentserver-core/tests/test_request_context.py b/sdk/agentserver/azure-ai-agentserver-core/tests/test_request_context.py index df771e23dc6e..9c59cb2af7ac 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/tests/test_request_context.py +++ b/sdk/agentserver/azure-ai-agentserver-core/tests/test_request_context.py @@ -7,7 +7,7 @@ import asyncio from azure.ai.agentserver.core import ( - RequestContext, + FoundryAgentRequestContext, get_request_context, reset_request_context, set_request_context, @@ -24,7 +24,7 @@ def test_default_is_empty(self) -> None: def test_set_and_reset(self) -> None: token = set_request_context( - RequestContext(call_id="c1", user_id="u1", session_id="s1") + FoundryAgentRequestContext(call_id="c1", user_id="u1", session_id="s1") ) try: ctx = get_request_context() @@ -36,23 +36,23 @@ def test_set_and_reset(self) -> None: assert get_request_context().call_id is None def test_platform_headers_includes_call_id(self) -> None: - ctx = RequestContext(call_id="cid", user_id="uid") + ctx = FoundryAgentRequestContext(call_id="cid", user_id="uid") headers = ctx.platform_headers() # Only the call ID is forwarded to 1P services; user_id is not. assert headers == {FOUNDRY_CALL_ID: "cid"} assert USER_ID not in headers def test_platform_headers_omits_absent_values(self) -> None: - assert RequestContext().platform_headers() == {} + assert FoundryAgentRequestContext().platform_headers() == {} # user_id alone never produces an outbound header. - assert RequestContext(user_id="uid").platform_headers() == {} - assert RequestContext(call_id="cid").platform_headers() == {FOUNDRY_CALL_ID: "cid"} + assert FoundryAgentRequestContext(user_id="uid").platform_headers() == {} + assert FoundryAgentRequestContext(call_id="cid").platform_headers() == {FOUNDRY_CALL_ID: "cid"} def test_context_propagates_into_child_task(self) -> None: - async def _run() -> RequestContext: - set_request_context(RequestContext(call_id="task-cid", user_id="task-uid")) + async def _run() -> FoundryAgentRequestContext: + set_request_context(FoundryAgentRequestContext(call_id="task-cid", user_id="task-uid")) - async def _child() -> RequestContext: + async def _child() -> FoundryAgentRequestContext: return get_request_context() # Child task created in this scope inherits the current context. diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md index cccd48848e88..63b9e0b2202c 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features Added -- Container protocol version `2.0.0` support: the toolbox MCP bridge now forwards the per-request call ID (`x-agent-foundry-call-id`) from the inbound request context on all outbound Foundry toolbox calls (initialize, `tools/list`, `tools/call`). No-op under protocol version `1.0.0` or local development. +- Container protocol version `2.0.0` support: the toolbox MCP bridge now echoes the per-request call ID (`x-agent-foundry-call-id`) on outbound Foundry toolbox calls. `initialize` / `tools/list` (run in the request task) read it from the request context; `tools/call` (dispatched on the Copilot engine task, where the request context var is empty) resolves it out-of-band keyed by the Copilot `session_id` and echoes it as both the HTTP header and `params._meta`. Copilot sessions are bound to a single user (`x-agent-user-id`) and never reused across users. No-op under protocol version `1.0.0` or local development. ## 1.0.0b2 (2026-04-24) diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_copilot_adapter.py b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_copilot_adapter.py index f82e5e9ef8c6..8ae399216001 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_copilot_adapter.py +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_copilot_adapter.py @@ -30,6 +30,7 @@ from copilot.session import PermissionRequestResult, ProviderConfig from azure.ai.agentserver.core import AgentServerHost # noqa: F401 (re-exported for subclasses) +from azure.ai.agentserver.core import get_request_context # pylint: disable=no-name-in-module from azure.ai.agentserver.responses import ( ResponseContext, ResponseEventStream, @@ -44,7 +45,12 @@ ) from ._tool_acl import ToolAcl -from ._toolbox import connect_toolbox, discover_mcp_servers +from ._toolbox import ( + clear_session_call_id, + connect_toolbox, + discover_mcp_servers, + set_session_call_id, +) logger = logging.getLogger("azure.ai.agentserver.githubcopilot") @@ -251,6 +257,11 @@ def __init__( # Multi-turn: conversation_id -> live CopilotSession self._sessions: Dict[str, Any] = {} + # §8.4 identity boundary: conversation_id -> owning x-agent-user-id. + # A Copilot session (and the per-turn call id bound to it) must belong + # to exactly one user; a session is never reused across users. + self._session_owner: Dict[str, str] = {} + # Credential for BYOK token refresh and MCP server auth. _has_byok_provider = ( "provider" in self._session_config @@ -310,9 +321,25 @@ def _on_permission(req, _ctx): async def _get_or_create_session(self, conversation_id=None): """Get existing session or create new one.""" + # §8.4: a session is bound to exactly one user. Never reuse it across + # users, or one user's tool call could echo another's call id. + request_user = get_request_context().user_id + if conversation_id and conversation_id in self._sessions: - logger.info(f"Reusing session for conversation {conversation_id!r}") - return self._sessions[conversation_id] + owner = self._session_owner.get(conversation_id) + if request_user is not None and owner is not None and owner != request_user: + logger.warning( + "Session for conversation %r is owned by a different user; " + "creating a fresh session to avoid cross-user identity bleed", + conversation_id, + ) + stale = self._sessions.pop(conversation_id, None) + self._session_owner.pop(conversation_id, None) + if stale is not None: + clear_session_call_id(getattr(stale, "session_id", None)) + else: + logger.info(f"Reusing session for conversation {conversation_id!r}") + return self._sessions[conversation_id] client = await self._ensure_client() config = self._refresh_token_if_needed() @@ -335,6 +362,8 @@ async def _get_or_create_session(self, conversation_id=None): if conversation_id: self._sessions[conversation_id] = session + if request_user is not None: + self._session_owner[conversation_id] = request_user logger.info( "Created new Copilot session" + (f" for conversation {conversation_id!r}" if conversation_id else "") @@ -392,6 +421,12 @@ async def _handle_create(self, request, context, cancellation_signal): session = await self._get_or_create_session(conversation_id) + # Bind this turn's call id to the Copilot session so that tool calls + # dispatched on the engine task — where the request-scoped context var + # is empty — can echo ``x-agent-foundry-call-id`` on outbound toolbox + # calls (container protocol ``2.0.0``; see bring-your-own §8). + set_session_call_id(getattr(session, "session_id", None), get_request_context().call_id) + # Set up event queue queue: asyncio.Queue = asyncio.Queue() @@ -1088,6 +1123,10 @@ async def _get_or_create_session(self, conversation_id=None): ) await session.send_and_wait(preamble, timeout=120.0) self._sessions[conversation_id] = session + # §8.4: bind the bootstrapped session to the requesting user. + _bootstrap_user = get_request_context().user_id + if _bootstrap_user is not None: + self._session_owner[conversation_id] = _bootstrap_user logger.info("Bootstrapped session %s with %d chars of history", conversation_id, len(history)) return await super()._get_or_create_session(conversation_id) diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py index 92f6297cf26d..5b9539f25cd4 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py @@ -37,6 +37,45 @@ _FOUNDRY_TOOLBOX_FEATURE_HEADER = "Toolboxes=V1Preview" _FOUNDRY_TOOLBOX_SERVER_KEY = "foundry-toolbox" _FOUNDRY_SCOPE = "https://ai.azure.com/.default" +_FOUNDRY_CALL_ID_HEADER = "x-agent-foundry-call-id" + + +# --------------------------------------------------------------------------- +# Per-turn call-id carried out-of-band, keyed by Copilot session id +# --------------------------------------------------------------------------- +# +# On container protocol version ``2.0.0`` the container must echo the inbound +# ``x-agent-foundry-call-id`` on outbound Foundry toolbox calls. The request- +# scoped context var (``get_request_context()``) is valid in the request task, +# but the Copilot engine dispatches ``tools/call`` on a separate, long-lived +# reader task where that context var is **empty**. So the per-turn call id is +# captured in the request task and stashed here keyed by the Copilot +# ``session_id`` — the only correlator that reaches the tool handler. +_call_id_by_session: Dict[str, str] = {} + + +def set_session_call_id(session_id: Optional[str], call_id: Optional[str]) -> None: + """Bind (or clear) the current turn's call id for a Copilot session. + + Called from the request task (where ``get_request_context()`` is valid). + Tool calls dispatched later on the Copilot engine task read this by + ``session_id``. + + :param session_id: The Copilot session id, or ``None`` (no-op). + :param call_id: The per-turn ``x-agent-foundry-call-id``, or ``None`` to clear. + """ + if not session_id: + return + if call_id: + _call_id_by_session[session_id] = call_id + else: + _call_id_by_session.pop(session_id, None) + + +def clear_session_call_id(session_id: Optional[str]) -> None: + """Remove any call id bound to a Copilot session (e.g. on eviction).""" + if session_id: + _call_id_by_session.pop(session_id, None) @@ -281,22 +320,40 @@ async def list_tools(self) -> List[Dict[str, Any]]: ) return tools - async def call_tool(self, name: str, arguments: Dict[str, Any]) -> str: + async def call_tool(self, name: str, arguments: Dict[str, Any], *, session_id: Optional[str] = None) -> str: """Call ``tools/call`` and return the text result. :param name: The original MCP tool name (not sanitised). :param arguments: Tool arguments dict. + :keyword session_id: The Copilot session id for this tool call. Used to + resolve the per-turn ``x-agent-foundry-call-id`` out-of-band, because + this method runs on the Copilot engine task where the request-scoped + context var is empty (container protocol ``2.0.0``). :returns: Formatted text result. """ logger.info("MCP tools/call: %s args=%s", name, list(arguments.keys())) + headers = self._request_headers() + params: Dict[str, Any] = {"name": name, "arguments": arguments} + + # Echo the per-turn call id resolved by session id. Stamp it both as the + # HTTP header (preferred) and in ``params._meta`` (fallback) — the + # platform accepts either. The session-keyed value overrides the (empty) + # request-context value for the engine-task dispatch. + call_id = _call_id_by_session.get(session_id) if session_id else None + if call_id: + headers[_FOUNDRY_CALL_ID_HEADER] = call_id + meta = dict(params.get("_meta") or {}) + meta[_FOUNDRY_CALL_ID_HEADER] = call_id + params["_meta"] = meta + resp = await self._client.post( self._endpoint, - headers=self._request_headers(), + headers=headers, json={ "jsonrpc": "2.0", "id": self._next_id(), "method": "tools/call", - "params": {"name": name, "arguments": arguments}, + "params": params, }, ) resp.raise_for_status() @@ -404,8 +461,11 @@ async def async_handler(invocation): args = getattr(invocation, "arguments", None) or {} if not isinstance(args, dict): args = {} + # session_id is the only correlator that reaches the engine-task + # tool dispatch; use it to resolve the per-turn call id (§2.0.0). + session_id = getattr(invocation, "session_id", None) try: - result_text = await bridge.call_tool(original_name, args) + result_text = await bridge.call_tool(original_name, args, session_id=session_id) return ToolResult(text_result_for_llm=result_text) except Exception as e: logger.warning("Tool %s failed: %s", original_name, e) diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py b/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py index ee0541c439f0..5140e739f090 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py @@ -351,13 +351,13 @@ class TestMcpBridgePlatformHeaders: def test_request_headers_forward_call_id(self): from azure.ai.agentserver.core import ( - RequestContext, + FoundryAgentRequestContext, reset_request_context, set_request_context, ) bridge = McpBridge("https://acct.services.ai.azure.com/toolboxes/t/mcp", {"X-Static": "1"}) - token = set_request_context(RequestContext(call_id="call-123", user_id="user-abc")) + token = set_request_context(FoundryAgentRequestContext(call_id="call-123", user_id="user-abc")) try: headers = bridge._request_headers() finally: @@ -376,3 +376,75 @@ def test_request_headers_omit_platform_context_when_absent(self): assert "x-agent-foundry-call-id" not in headers assert "x-agent-user-id" not in headers assert headers["X-Static"] == "1" + + +# --------------------------------------------------------------------------- +# Session-keyed per-turn call-id carry on tools/call (container protocol 2.0.0, §8) +# --------------------------------------------------------------------------- + +set_session_call_id = _toolbox.set_session_call_id +clear_session_call_id = _toolbox.clear_session_call_id + + +def _fake_post_response(payload): + resp = mock.Mock() + resp.raise_for_status = mock.Mock() + resp.json = mock.Mock(return_value=payload) + resp.headers = {} + return resp + + +class TestSessionKeyedCallId: + """tools/call must echo the per-turn call id resolved by session_id, because + it runs on the Copilot engine task where the request context var is empty.""" + + @pytest.mark.asyncio + async def test_call_tool_stamps_session_call_id_header_and_meta(self): + bridge = McpBridge("https://acct.services.ai.azure.com/toolboxes/t/mcp", {}) + captured = {} + + async def _post(url, headers=None, json=None): + captured["headers"] = headers + captured["json"] = json + return _fake_post_response({"result": {"content": [{"type": "text", "text": "ok"}]}}) + + bridge._client.post = _post + set_session_call_id("sess-1", "call-xyz") + try: + out = await bridge.call_tool("doc.search", {"q": "x"}, session_id="sess-1") + finally: + clear_session_call_id("sess-1") + + assert out == "ok" + # Echoed as header (preferred) and in params._meta (fallback). + assert captured["headers"]["x-agent-foundry-call-id"] == "call-xyz" + assert captured["json"]["params"]["_meta"]["x-agent-foundry-call-id"] == "call-xyz" + + @pytest.mark.asyncio + async def test_call_tool_no_call_id_when_session_unbound(self): + bridge = McpBridge("https://acct.services.ai.azure.com/toolboxes/t/mcp", {}) + captured = {} + + async def _post(url, headers=None, json=None): + captured["headers"] = headers + captured["json"] = json + return _fake_post_response({"result": {"content": [{"type": "text", "text": "ok"}]}}) + + bridge._client.post = _post + out = await bridge.call_tool("doc.search", {"q": "x"}, session_id="unknown-sess") + + assert out == "ok" + assert "x-agent-foundry-call-id" not in captured["headers"] + assert "_meta" not in captured["json"]["params"] + + def test_set_and_clear_session_call_id(self): + set_session_call_id("s", "c") + assert _toolbox._call_id_by_session.get("s") == "c" + set_session_call_id("s", None) # clear via None + assert "s" not in _toolbox._call_id_by_session + set_session_call_id("s", "c2") + clear_session_call_id("s") + assert "s" not in _toolbox._call_id_by_session + # no-op for falsy session id + set_session_call_id(None, "c") + assert None not in _toolbox._call_id_by_session diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation.py b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation.py index 4dc4053d10eb..fd2f6aff3aeb 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation.py +++ b/sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation.py @@ -22,7 +22,7 @@ from azure.ai.agentserver.core import ( # pylint: disable=no-name-in-module AgentServerHost, - RequestContext, + FoundryAgentRequestContext, create_error_response, reset_request_context, set_request_context, @@ -365,7 +365,7 @@ def _wrap_streaming_response( response: StreamingResponse, invocation_id: str, session_id: str, - platform_context: RequestContext | None = None, + platform_context: FoundryAgentRequestContext | None = None, ) -> StreamingResponse: """Wrap streaming body iteration with invocation logging/tracing context. @@ -377,7 +377,7 @@ def _wrap_streaming_response( :type session_id: str :param platform_context: Platform context to re-establish for outbound 1P calls made during stream iteration (protocol 2.0.0). - :type platform_context: ~azure.ai.agentserver.core.RequestContext | None + :type platform_context: ~azure.ai.agentserver.core.FoundryAgentRequestContext | None :return: The response with a wrapped body_iterator. :rtype: StreamingResponse """ @@ -453,7 +453,7 @@ async def _create_invocation_endpoint(self, request: Request) -> Response: session_token = _session_id_var.set(session_id) # Bind platform context so outbound 1P calls (and handler/tool code) can # forward the per-request call ID and user ID (protocol 2.0.0). - platform_ctx = RequestContext( + platform_ctx = FoundryAgentRequestContext( call_id=call_id or None, user_id=user_id or None, session_id=session_id, diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py index 1effa392f480..a1ace67c8aa7 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py @@ -22,7 +22,7 @@ from starlette.responses import JSONResponse, Response, StreamingResponse from azure.ai.agentserver.core import ( # pylint: disable=import-error,no-name-in-module - RequestContext, + FoundryAgentRequestContext, flush_spans, reset_request_context, set_request_context, @@ -662,7 +662,7 @@ async def handle_create(self, request: Request) -> Response: # pylint: disable= # Bind platform context so handler/tool code making raw outbound 1P calls # can forward the per-request call ID and user ID (protocol 2.0.0). platform_ctx_token = set_request_context( - RequestContext( + FoundryAgentRequestContext( call_id=ctx.call_id or None, user_id=ctx.user_id or None, session_id=agent_session_id, From df4999f08ca6b4085763fdbd8fdd25d2ff28e851 Mon Sep 17 00:00:00 2001 From: Pranav Pandit Date: Sun, 21 Jun 2026 01:51:02 +0000 Subject: [PATCH 06/11] [agentserver] Add multi-user (per-request call ID) README examples; fix spelling - Add toolbox-centric "multi-user session" examples to the core, responses, invocations, and ghcopilot READMEs: forward the per-request x-agent-foundry-call-id on outbound toolbox calls so a tool server resolves the caller per request on a shared session (x-agent-user-id is never echoed). - Document request.state.user_id / request.state.call_id in invocations. - Fix cspell errors: rename test session ids "sess-*" -> "session-*". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure-ai-agentserver-core/README.md | 30 +++++++++++++ .../azure-ai-agentserver-ghcopilot/README.md | 12 ++++++ .../tests/unit_tests/test_toolbox.py | 8 ++-- .../README.md | 38 +++++++++++++++++ .../azure-ai-agentserver-responses/README.md | 42 +++++++++++++++++++ 5 files changed, 126 insertions(+), 4 deletions(-) diff --git a/sdk/agentserver/azure-ai-agentserver-core/README.md b/sdk/agentserver/azure-ai-agentserver-core/README.md index add29e0bb57b..2a9d56c42346 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/README.md +++ b/sdk/agentserver/azure-ai-agentserver-core/README.md @@ -60,6 +60,36 @@ async def handle(request): app.run() ``` +### Per-request identity (multi-user sessions) + +On container protocol `2.0.0` a single agent session can serve **multiple users**. Each request carries `x-agent-user-id` (the user — partition state by it) and an opaque `x-agent-foundry-call-id` (the per-request caller identity). Read both via `get_request_context()`; the SDK forwards **only** the call ID on outbound Foundry calls — `x-agent-user-id` is never echoed. Forwarding the call ID lets a tool server resolve which user made the request and act on their behalf. + +```python +import os + +import httpx +from azure.ai.agentserver.core import get_request_context + + +def foundry_headers() -> dict[str, str]: + # Echoes x-agent-foundry-call-id only; x-agent-user-id is never forwarded. + return dict(get_request_context().platform_headers()) + + +async def call_toolbox(query: str) -> str: + user_id = get_request_context().user_id # for the container's OWN per-user state + # Attach the call ID PER CALL — a toolbox MCP session is long-lived and serves many + # users/turns, so never bake one call's ID into the client's static headers. + async with httpx.AsyncClient() as mcp: + resp = await mcp.post( + f"{os.environ['FOUNDRY_PROJECT_ENDPOINT']}/toolboxes/github/mcp", + headers={"Authorization": f"Bearer {get_agent_token()}", **foundry_headers()}, + json={"jsonrpc": "2.0", "method": "tools/call", + "params": {"name": "list_my_assigned_issues", "arguments": {}}}, + ) + return resp.text # the toolbox resolved the caller from the call ID and acted as that user +``` + ### Subclassing AgentServerHost For custom protocol implementations, subclass `AgentServerHost` and add routes: diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/README.md b/sdk/agentserver/azure-ai-agentserver-ghcopilot/README.md index c602bd6dda87..bf101db16a6b 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/README.md +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/README.md @@ -114,6 +114,18 @@ adapter = GitHubCopilotAdapter.from_project(".") adapter.run() ``` +### Multi-user sessions (per-request call ID) + +On container protocol `2.0.0` a single agent can serve **multiple users** — and the adapter handles it for you. It echoes the per-request `x-agent-foundry-call-id` on outbound **Foundry toolbox** calls so each `tools/call` resolves the correct caller and acts on their behalf. Because the Copilot engine dispatches tools on a separate task, the call ID is carried per turn keyed by the Copilot `session_id` and stamped on both the request header and the MCP `params._meta`. Copilot sessions are bound to a single user (`x-agent-user-id`) and never reused across users — so no agent code change is needed. + +```python +from azure.ai.agentserver.githubcopilot import GitHubCopilotAdapter + +# Toolbox call IDs are forwarded automatically per request; sessions never cross users. +adapter = GitHubCopilotAdapter.from_project(".") +adapter.run() +``` + ### With custom credential ```python diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py b/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py index 5140e739f090..969775f0f7c0 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py @@ -409,11 +409,11 @@ async def _post(url, headers=None, json=None): return _fake_post_response({"result": {"content": [{"type": "text", "text": "ok"}]}}) bridge._client.post = _post - set_session_call_id("sess-1", "call-xyz") + set_session_call_id("session-1", "call-xyz") try: - out = await bridge.call_tool("doc.search", {"q": "x"}, session_id="sess-1") + out = await bridge.call_tool("doc.search", {"q": "x"}, session_id="session-1") finally: - clear_session_call_id("sess-1") + clear_session_call_id("session-1") assert out == "ok" # Echoed as header (preferred) and in params._meta (fallback). @@ -431,7 +431,7 @@ async def _post(url, headers=None, json=None): return _fake_post_response({"result": {"content": [{"type": "text", "text": "ok"}]}}) bridge._client.post = _post - out = await bridge.call_tool("doc.search", {"q": "x"}, session_id="unknown-sess") + out = await bridge.call_tool("doc.search", {"q": "x"}, session_id="unknown-session") assert out == "ok" assert "x-agent-foundry-call-id" not in captured["headers"] diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/README.md b/sdk/agentserver/azure-ai-agentserver-invocations/README.md index 913e95d7329e..aa06d288917e 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/README.md +++ b/sdk/agentserver/azure-ai-agentserver-invocations/README.md @@ -65,6 +65,8 @@ Inside handler functions, the SDK sets these attributes on `request.state`: - `request.state.invocation_id` — The invocation ID (echoed or generated). - `request.state.session_id` — The resolved session ID (POST /invocations only). +- `request.state.user_id` — The per-user ID from `x-agent-user-id` (container protocol `2.0.0`); use for per-user state. +- `request.state.call_id` — The per-request call ID from `x-agent-foundry-call-id` (protocol `2.0.0`); forward on outbound Foundry calls. ### Distributed tracing @@ -95,6 +97,42 @@ async def handle(request: Request) -> Response: app.run() ``` +### Multi-user session (per-request call ID) + +On container protocol `2.0.0` a single agent session can serve **multiple users**. Forwarding the per-request `x-agent-foundry-call-id` on outbound toolbox calls lets the tool server resolve *which* user made this request and act on their behalf. (`x-agent-user-id` is never forwarded; the tool resolves the user from the call ID server-side. Use `request.state.user_id` only for the container's own per-user state.) + +```python +import os + +import httpx +from azure.ai.agentserver.core import get_request_context +from azure.ai.agentserver.invocations import InvocationAgentServerHost +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +app = InvocationAgentServerHost() + + +@app.invoke_handler +async def handle(request: Request) -> Response: + # platform_headers() echoes x-agent-foundry-call-id only (never x-agent-user-id). + headers = get_request_context().platform_headers() + + # Toolbox / MCP — attach the call ID PER CALL (the MCP session is shared across users/turns). + async with httpx.AsyncClient() as mcp: + resp = await mcp.post( + f"{os.environ['FOUNDRY_PROJECT_ENDPOINT']}/toolboxes/github/mcp", + headers={"Authorization": f"Bearer {get_agent_token()}", **headers}, # get_agent_token(): the agent's managed-identity token + json={"jsonrpc": "2.0", "method": "tools/call", + "params": {"name": "list_my_assigned_issues", "arguments": {}}}, + ) + # The toolbox resolved the caller from the call ID and returned THIS user's issues. + + return JSONResponse(resp.json()) + +app.run() +``` + ### Long-running operations with polling ```python diff --git a/sdk/agentserver/azure-ai-agentserver-responses/README.md b/sdk/agentserver/azure-ai-agentserver-responses/README.md index d67758f8345e..5e8be3aa93ba 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/README.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/README.md @@ -136,6 +136,48 @@ async def handler(request: CreateResponse, context: ResponseContext, cancellatio return TextResponse(context, request, text=f"Echo: {text}") +app.run() +``` + +### Multi-user session (per-request call ID) + +On container protocol `2.0.0` a single agent session can serve **multiple users**. Forwarding the per-request `x-agent-foundry-call-id` on outbound toolbox calls lets the tool server resolve *which* user made this request and act on their behalf — so user A's and user B's requests to the same session each get a user-scoped result. (`x-agent-user-id` is never forwarded; the tool resolves the user from the call ID server-side. Use `context.platform_context.user_id_key` only for the container's own per-user state.) + +```python +import asyncio +import os + +import httpx +from azure.ai.agentserver.core import get_request_context +from azure.ai.agentserver.responses import ( + CreateResponse, + ResponseContext, + ResponsesAgentServerHost, + TextResponse, +) + +app = ResponsesAgentServerHost() + + +@app.response_handler +async def handler(request: CreateResponse, context: ResponseContext, cancellation_signal: asyncio.Event): + # platform_headers() echoes x-agent-foundry-call-id only (never x-agent-user-id). + headers = get_request_context().platform_headers() + + # Toolbox / MCP — attach the call ID PER CALL. The MCP session is long-lived and + # shared across users/turns, so never bake one call's ID into static client headers. + async with httpx.AsyncClient() as mcp: + resp = await mcp.post( + f"{os.environ['FOUNDRY_PROJECT_ENDPOINT']}/toolboxes/github/mcp", + headers={"Authorization": f"Bearer {get_agent_token()}", **headers}, # get_agent_token(): the agent's managed-identity token + json={"jsonrpc": "2.0", "method": "tools/call", + "params": {"name": "list_my_assigned_issues", "arguments": {}}}, + ) + # The toolbox resolved the caller from the call ID and returned THIS user's issues. + + return TextResponse(context, request, text=resp.text) + + app.run() ``` From f94ad7701f07339cde3b6140858f6282daae58ea Mon Sep 17 00:00:00 2001 From: Pranav Pandit Date: Sun, 21 Jun 2026 04:36:16 +0000 Subject: [PATCH 07/11] Disable mindependency for responses/invocations (unpublished core 2.0.0b7 floor) These packages now require azure-ai-agentserver-core>=2.0.0b7 for the new FoundryAgentRequestContext / PlatformContext APIs. Until core 2.0.0b7 is published, the minimum-dependency check cannot resolve a satisfying core version, so disable it here (matching the azure-ai-agentserver-ghcopilot sibling precedent). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/agentserver/azure-ai-agentserver-invocations/pyproject.toml | 1 + sdk/agentserver/azure-ai-agentserver-responses/pyproject.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/sdk/agentserver/azure-ai-agentserver-invocations/pyproject.toml b/sdk/agentserver/azure-ai-agentserver-invocations/pyproject.toml index af34178271a0..a21545753fab 100644 --- a/sdk/agentserver/azure-ai-agentserver-invocations/pyproject.toml +++ b/sdk/agentserver/azure-ai-agentserver-invocations/pyproject.toml @@ -68,6 +68,7 @@ mypy = true pyright = true verifytypes = false latestdependency = false +mindependency = false pylint = true type_check_samples = false diff --git a/sdk/agentserver/azure-ai-agentserver-responses/pyproject.toml b/sdk/agentserver/azure-ai-agentserver-responses/pyproject.toml index 2f11803ab67b..ebce0ee5d7c6 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/pyproject.toml +++ b/sdk/agentserver/azure-ai-agentserver-responses/pyproject.toml @@ -69,3 +69,4 @@ azure-sdk-tools = { path = "../../../eng/tools/azure-sdk-tools" } [tool.azure-sdk-build] verifytypes = false latestdependency = false +mindependency = false From ffa6e0462289181b645314ed73c70a7ed30e90d0 Mon Sep 17 00:00:00 2001 From: Pranav Pandit Date: Sun, 21 Jun 2026 05:01:11 +0000 Subject: [PATCH 08/11] Re-establish platform request context for streaming response bodies For streaming requests the registered handler runs lazily while Starlette iterates the StreamingResponse body, which happens after handle_create has returned and its finally block already called reset_request_context(). As a result get_request_context() returned the empty context inside any streaming handler, so the per-request x-agent-foundry-call-id / x-agent-user-id could not be forwarded on raw outbound 1P calls (protocol 2.0.0). This also broke the ghcopilot adapter, whose create handler is a streaming async generator that reads get_request_context().call_id / .user_id. Re-establish the platform context inside the streaming body iterator (for both background and non-background streams), mirroring the invocations package's _wrap_streaming_response. Added a regression test that fails without the fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../responses/hosting/_endpoint_handler.py | 40 ++++---- .../test_streaming_request_context.py | 93 +++++++++++++++++++ 2 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_streaming_request_context.py diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py index a1ace67c8aa7..0f9cbfe39ee6 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_endpoint_handler.py @@ -661,38 +661,42 @@ async def handle_create(self, request: Request) -> Response: # pylint: disable= str_token = _streaming_var.set(str(ctx.stream).lower()) # Bind platform context so handler/tool code making raw outbound 1P calls # can forward the per-request call ID and user ID (protocol 2.0.0). - platform_ctx_token = set_request_context( - FoundryAgentRequestContext( - call_id=ctx.call_id or None, - user_id=ctx.user_id or None, - session_id=agent_session_id, - ) + platform_context = FoundryAgentRequestContext( + call_id=ctx.call_id or None, + user_id=ctx.user_id or None, + session_id=agent_session_id, ) + platform_ctx_token = set_request_context(platform_context) disconnect_task: asyncio.Task[None] | None = None try: if ctx.stream: - body_iter = self._orchestrator.run_stream(ctx) + raw_iter = self._orchestrator.run_stream(ctx) # B17: monitor client disconnect for non-background streams if not ctx.background: disconnect_task = asyncio.create_task( self._monitor_disconnect(request, ctx.cancellation_signal) ) - raw_iter = body_iter - - async def _iter_with_cleanup(): # type: ignore[return] - try: - async for chunk in raw_iter: - yield chunk - finally: - if disconnect_task and not disconnect_task.done(): - disconnect_task.cancel() - body_iter = _iter_with_cleanup() + # The handler runs lazily while Starlette iterates this body, which + # happens after handle_create returns (line below) and the `finally` + # has already reset the request context. Re-establish the platform + # context inside the streaming task so handler/tool code can still + # forward the per-request call ID / user ID (protocol 2.0.0), and + # fold in disconnect-task cleanup. + async def _iter_with_context(): # type: ignore[return] + stream_ctx_token = set_request_context(platform_context) + try: + async for chunk in raw_iter: + yield chunk + finally: + reset_request_context(stream_ctx_token) + if disconnect_task and not disconnect_task.done(): + disconnect_task.cancel() sse_response = StreamingResponse( - body_iter, + _iter_with_context(), media_type="text/event-stream", headers={**self._sse_headers, **self._session_headers(agent_session_id)}, ) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_streaming_request_context.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_streaming_request_context.py new file mode 100644 index 000000000000..12c25e8d529f --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_streaming_request_context.py @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Contract test: the platform request context is available inside streaming handlers. + +For streaming requests the registered handler does not run during +``handle_create``; it runs lazily while Starlette iterates the +``StreamingResponse`` body. The request context bound in ``handle_create`` is +reset in its ``finally`` block before that iteration begins, so the streaming +body must re-establish the platform context itself. Otherwise +``get_request_context()`` returns the empty context inside any streaming +handler and the per-request ``x-agent-foundry-call-id`` / ``x-agent-user-id`` +cannot be forwarded on raw outbound 1P calls (protocol 2.0.0). + +Regression test for: request context not re-established for the streaming body. +""" + +from __future__ import annotations + +from typing import Any + +from starlette.testclient import TestClient + +from azure.ai.agentserver.core import get_request_context +from azure.ai.agentserver.responses import ResponsesAgentServerHost +from azure.ai.agentserver.responses.store._memory import InMemoryResponseProvider +from azure.ai.agentserver.responses.streaming import ResponseEventStream + + +def _build_capturing_client(captured: dict[str, Any]) -> TestClient: + def _capturing_handler(request: Any, context: Any, cancellation_signal: Any) -> Any: + async def _events(): + # This runs while Starlette iterates the streaming body, AFTER + # handle_create returned and reset its own request context. + rc = get_request_context() + captured["call_id"] = rc.call_id + captured["user_id"] = rc.user_id + captured["session_id"] = rc.session_id + + stream = ResponseEventStream( + response_id=context.response_id, model=getattr(request, "model", None) + ) + yield stream.emit_created() + yield stream.emit_completed() + + return _events() + + app = ResponsesAgentServerHost(store=InMemoryResponseProvider()) + app.response_handler(_capturing_handler) + return TestClient(app) + + +class TestStreamingRequestContext: + """The platform context must be visible inside streaming handler bodies.""" + + def test_streaming_handler_sees_call_id_and_user_id(self) -> None: + captured: dict[str, Any] = {} + client = _build_capturing_client(captured) + + headers = { + "x-agent-user-id": "user_123", + "x-agent-foundry-call-id": "call_456", + } + with client.stream( + "POST", + "/responses", + json={"model": "m", "input": "hi", "stream": True, "store": False}, + headers=headers, + ) as r: + assert r.status_code == 200 + # Force the SSE body to iterate fully so the handler runs. + for _ in r.iter_lines(): + pass + + assert captured.get("call_id") == "call_456" + assert captured.get("user_id") == "user_123" + + def test_streaming_handler_without_headers_sees_empty_context(self) -> None: + captured: dict[str, Any] = {} + client = _build_capturing_client(captured) + + with client.stream( + "POST", + "/responses", + json={"model": "m", "input": "hi", "stream": True, "store": False}, + ) as r: + assert r.status_code == 200 + for _ in r.iter_lines(): + pass + + # No identity headers → context fields are None (but the handler still ran). + assert "call_id" in captured + assert captured.get("call_id") is None + assert captured.get("user_id") is None From a4783a5e408d76707be6cfa40dbe18f880692718 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Jun 2026 08:05:53 +0000 Subject: [PATCH 09/11] Fix misleading comment in _toolbox.py: only call ID is forwarded to 1P, not user ID Co-authored-by: vangarp <28958073+vangarp@users.noreply.github.com> --- .../azure/ai/agentserver/githubcopilot/_toolbox.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py index 5b9539f25cd4..1bfa4f6a2f05 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py @@ -221,9 +221,10 @@ def _request_headers(self) -> Dict[str, str]: logger.warning("Failed to refresh token for MCP bridge", exc_info=True) if self._session_id: headers["mcp-session-id"] = self._session_id - # Forward platform identity headers (call ID / user ID) to the Foundry + # Forward the per-request call ID (x-agent-foundry-call-id) to the Foundry # toolbox service on container protocol version 2.0.0. No-op when absent - # (protocol 1.0.0 or local development). + # (protocol 1.0.0 or local development). x-agent-user-id is never forwarded + # to 1P services; it is used only for container-side per-user partitioning. headers.update(get_request_context().platform_headers()) return headers From 8f3dd740536a085887af10dc18eee961c5db1e13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Jun 2026 08:31:07 +0000 Subject: [PATCH 10/11] fix: use correct class name in CHANGELOG and import FOUNDRY_CALL_ID from core in _toolbox Co-authored-by: vangarp <28958073+vangarp@users.noreply.github.com> --- sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md | 2 +- .../azure/ai/agentserver/githubcopilot/_toolbox.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md index 1ba490f59cb0..8891a5c6b131 100644 --- a/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md @@ -6,7 +6,7 @@ - Container protocol version `2.0.0` support: added the platform identity header constants `x-agent-user-id` (`USER_ID`) — the global, cross-agent per-user partition key — and `x-agent-foundry-call-id` (`FOUNDRY_CALL_ID`) — the opaque per-request call identifier — to the `_platform_headers` module. - Added `FOUNDRY_AGENT_ID` environment variable support exposing the agent's stable GUID via `AgentConfig.agent_guid` and the `resolve_agent_guid()` helper. -- Added a request-scoped platform context: `FoundryAgentRequestContext`, `get_request_context()`, `set_request_context()`, and `reset_request_context()`. Protocol packages bind the inbound per-request call ID and user ID so that handler code (and the SDK HTTP pipeline) can read them. `RequestContext.platform_headers()` builds the headers to forward on outbound Foundry 1P calls — the per-request call ID only; `x-agent-user-id` is **not** forwarded (it is not accepted/trusted by 1P services and is used only for container-side state partitioning). +- Added a request-scoped platform context: `FoundryAgentRequestContext`, `get_request_context()`, `set_request_context()`, and `reset_request_context()`. Protocol packages bind the inbound per-request call ID and user ID so that handler code (and the SDK HTTP pipeline) can read them. `FoundryAgentRequestContext.platform_headers()` builds the headers to forward on outbound Foundry 1P calls — the per-request call ID only; `x-agent-user-id` is **not** forwarded (it is not accepted/trusted by 1P services and is used only for container-side state partitioning). ### Breaking Changes diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py index 1bfa4f6a2f05..0879ac33e84d 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py @@ -27,6 +27,7 @@ from copilot.tools import Tool, ToolResult from azure.ai.agentserver.core import get_request_context # pylint: disable=no-name-in-module +from azure.ai.agentserver.core._platform_headers import FOUNDRY_CALL_ID # pylint: disable=no-name-in-module logger = logging.getLogger("azure.ai.agentserver.githubcopilot") @@ -37,7 +38,6 @@ _FOUNDRY_TOOLBOX_FEATURE_HEADER = "Toolboxes=V1Preview" _FOUNDRY_TOOLBOX_SERVER_KEY = "foundry-toolbox" _FOUNDRY_SCOPE = "https://ai.azure.com/.default" -_FOUNDRY_CALL_ID_HEADER = "x-agent-foundry-call-id" # --------------------------------------------------------------------------- @@ -342,9 +342,9 @@ async def call_tool(self, name: str, arguments: Dict[str, Any], *, session_id: O # request-context value for the engine-task dispatch. call_id = _call_id_by_session.get(session_id) if session_id else None if call_id: - headers[_FOUNDRY_CALL_ID_HEADER] = call_id + headers[FOUNDRY_CALL_ID] = call_id meta = dict(params.get("_meta") or {}) - meta[_FOUNDRY_CALL_ID_HEADER] = call_id + meta[FOUNDRY_CALL_ID] = call_id params["_meta"] = meta resp = await self._client.post( From 94daa1b5326b9fa63ce08cf5860596fbefb771c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Jun 2026 09:32:50 +0000 Subject: [PATCH 11/11] Remove extra blank line in _toolbox.py (PEP 8 / black compliance) Co-authored-by: vangarp <28958073+vangarp@users.noreply.github.com> --- .../azure/ai/agentserver/githubcopilot/_toolbox.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py index 0879ac33e84d..7df9d61fc97c 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py @@ -78,7 +78,6 @@ def clear_session_call_id(session_id: Optional[str]) -> None: _call_id_by_session.pop(session_id, None) - # --------------------------------------------------------------------------- # Discovery — read mcp.json and build server config dicts # ---------------------------------------------------------------------------