Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions sdk/agentserver/azure-ai-agentserver-core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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: `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

- 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
Expand Down
30 changes: 30 additions & 0 deletions sdk/agentserver/azure-ai-agentserver-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
from azure.ai.agentserver.core import (
AgentConfig,
AgentServerHost,
FoundryAgentRequestContext,
configure_observability,
create_error_response,
detach_context,
end_span,
flush_spans,
get_request_context,
record_error,
set_current_span,
trace_stream,
Expand All @@ -27,6 +29,12 @@
from ._config import AgentConfig
from ._errors import create_error_response
from ._middleware import InboundRequestLoggingMiddleware
from ._request_context import (
FoundryAgentRequestContext,
get_request_context,
reset_request_context,
set_request_context,
)
from ._request_id import RequestIdMiddleware
from ._server_version import build_server_version
from ._tracing import (
Expand All @@ -44,15 +52,19 @@
"AgentConfig",
"AgentServerHost",
"InboundRequestLoggingMiddleware",
"FoundryAgentRequestContext",
"RequestIdMiddleware",
"build_server_version",
"configure_observability",
"create_error_response",
"detach_context",
"end_span",
"flush_spans",
"get_request_context",
"record_error",
"reset_request_context",
"set_current_span",
"set_request_context",
"trace_stream",
]
__version__ = VERSION
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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``.
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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, ""),
Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 ---------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# ---------------------------------------------------------
# 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

__all__ = [
"FoundryAgentRequestContext",
"get_request_context",
"set_request_context",
"reset_request_context",
]


class FoundryAgentRequestContext:
"""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 ``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.call_id is not None:
headers[FOUNDRY_CALL_ID] = self.call_id
return headers


_EMPTY = FoundryAgentRequestContext()

_request_context_var: ContextVar[FoundryAgentRequestContext] = ContextVar("agentserver_request_context")


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:`FoundryAgentRequestContext` with all-``None`` fields is returned.

:return: The current request-scoped platform context.
:rtype: FoundryAgentRequestContext
"""
return _request_context_var.get(_EMPTY)


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
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: 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[FoundryAgentRequestContext]) -> 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)
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# ---------------------------------------------------------

VERSION = "2.0.0b6"
VERSION = "2.0.0b7"
23 changes: 23 additions & 0 deletions sdk/agentserver/azure-ai-agentserver-core/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading