.NET: Add Hosted-MemoryAgent sample with isolation key plumbing (#5692) (#5702)

* .NET: Add Hosted-MemoryAgent sample with isolation key plumbing (#5692)

Adds HostedSessionContext + HostedSessionIsolationKeyProvider in Microsoft.Agents.AI.Foundry.Hosting so AIContextProviders (notably FoundryMemoryProvider) can scope per user via the platform's x-agent-user-isolation-key / x-agent-chat-isolation-key headers.

- New types: HostedSessionContext (sealed), HostedSessionContextExtensions (public Get, internal Set), abstract HostedSessionIsolationKeyProvider (async), internal PlatformHostedSessionIsolationKeyProvider mapping ResponseContext.Isolation.

- AgentFrameworkResponseHandler now resolves the provider, tags fresh sessions, and validates resumed sessions against the live request (strict 403 'Hosted session identity context mismatch' on any mismatch; 500 on null keys).

- New shared sample project Hosted_Shared_Contributor_Setup hosts DevTemporaryTokenCredential and DevTemporaryLocalSessionIsolationKeyProvider plus AddDevTemporaryLocalContributorSetup. All 9 existing responses samples migrated to consume it so local runs keep working under the strict isolation contract.

- New Hosted-MemoryAgent sample: travel assistant wired through FoundryMemoryProvider with stateInitializer reading session.GetHostedContext().UserId. Includes Dockerfile, smoke.ps1, agent.yaml/manifest.

- New IT scenario 'memory' in Foundry.Hosting.IntegrationTests + MemoryHostedAgentFixture + MemoryHostedAgentTests. Verified end to end against the tao Foundry project.

- ADR 0026 captures the design tree.

* Address PR review feedback

- Dockerfile: add header noting it targets NuGet builds; contributors must use Dockerfile.contributor for ProjectReference source builds.

- PlatformHostedSessionIsolationKeyProvider: doc said 'returns context with empty values'; corrected to 'returns null' which the handler treats as 500.

- FakeHostedSessionIsolationKeyProvider: doc clarifies that null configurations are allowed for testing the handler error path.

- HostedSessionContextExtensions.SetHostedContext: enforce write-once with InvalidOperationException; doc + xml exception updated.

- AgentFrameworkResponseHandler: cache PlatformHostedSessionIsolationKeyProvider as static readonly to avoid per-request allocation.

- MemoryHostedAgentTests: tighten waits from 20s to 5s (FoundryMemoryProvider defaults UpdateDelay=0; ingestion ~3s).

- Sample Program.cs imports reordered to satisfy IDE0005.

* Add HostedFoundryMemoryProviderScopes built-in helpers (#5692)

Addresses review feedback from @lokitoth on Hosted-MemoryAgent/Program.cs:54.

- New HostedFoundryMemoryProviderScopes static class with PerUser, PerChat, PerUserAndChat factories returning Func<AgentSession?, FoundryMemoryProvider.State>.

- All helpers throw InvalidOperationException when GetHostedContext() is null, with a message pointing at writing a custom stateInitializer for non-hosted scenarios.

- New HostedFoundryMemoryScope enum and AddHostedFoundryMemoryProvider DI extension (two overloads: explicit AIProjectClient and DI-resolved). Singleton lifetime. Default scope = PerUser.

- Hosted-MemoryAgent sample and the memory IT scenario container both swap their inline lambdas for HostedFoundryMemoryProviderScopes.PerUser().

- 14 new unit tests (241/241 hosting unit tests pass).

* Replace HostedFoundryMemoryScope enum with Func<...> parameter (#5692)

Address PR review feedback from @westey-m: enums are a breaking-change hazard when extended, and the enum was redundant with the existing HostedFoundryMemoryProviderScopes static class.

- Delete HostedFoundryMemoryScope.cs.

- AddHostedFoundryMemoryProvider DI extensions now take Func<AgentSession?, FoundryMemoryProvider.State>? stateInitializer = null. When null, default to HostedFoundryMemoryProviderScopes.PerUser().

- Callers pick a built-in helper (PerUser/PerChat/PerUserAndChat) or pass a custom delegate. New built-ins are a single static method addition with zero impact on existing callers.

- Tests updated; 244/244 hosting unit tests pass.

* Fix isolation context resume for externally-created conversations (#5692)

Branch on the session's existing hosted-context (not on conversation_id presence) so a conversation provisioned externally (e.g. via conversations.CreateProjectConversationAsync) is treated as fresh on first hosted-agent request and stamped, rather than rejected with 403 hosted_session_identity_mismatch. Strict equality is preserved on real resume of an already-stamped session.

Also tighten dotnet/global.json to version 10.0.204 + rollForward latestPatch so local builds match the CI Docker image SDK and avoid 10.0.300 dotnet format stripping required usings.

* Revert global.json SDK pin to upstream (#5692)

The 10.0.204 + latestPatch pin from the previous commit broke the dotnet-format CI job (hostfxr_resolve_sdk2 could not find a compatible SDK in the mcr.microsoft.com/dotnet/sdk:10.0 image). Restore upstream 10.0.200 + minor; local Release builds with SDK 10.0.300 should set GITHUB_ACTIONS=true to bypass the auto-format-on-build target.
This commit is contained in:
Roger Barreto
2026-05-15 06:42:12 +01:00
committed by GitHub
Unverified
parent 97eaef029e
commit ad95f2f2fa
53 changed files with 2060 additions and 330 deletions
@@ -26,6 +26,12 @@ public class AgentFrameworkResponseHandler : ResponseHandler
private readonly ILogger<AgentFrameworkResponseHandler> _logger;
private readonly FoundryToolboxService? _toolboxService;
/// <summary>
/// Cached fallback used when no <see cref="HostedSessionIsolationKeyProvider"/> is registered in DI.
/// Avoids a per-request allocation on the request hot path.
/// </summary>
private static readonly HostedSessionIsolationKeyProvider s_defaultIsolationKeyProvider = new PlatformHostedSessionIsolationKeyProvider();
/// <summary>
/// Initializes a new instance of the <see cref="AgentFrameworkResponseHandler"/> class
/// that resolves agents from keyed DI services.
@@ -67,6 +73,42 @@ public class AgentFrameworkResponseHandler : ResponseHandler
? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false)
: await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
// 2.5. Resolve and apply the per-request hosted session identity context.
// Fresh sessions are tagged once. Resumed sessions are validated against the live request
// to detect cross-user session leaks and in-process tampering of the persisted identity.
var isolationKeyProvider = this._serviceProvider.GetService<HostedSessionIsolationKeyProvider>()
?? s_defaultIsolationKeyProvider;
var resolvedHostedContext = await isolationKeyProvider.GetKeysAsync(context, request, cancellationToken).ConfigureAwait(false);
if (resolvedHostedContext is null)
{
throw new InvalidOperationException(
$"The registered {nameof(HostedSessionIsolationKeyProvider)} returned null for the current request. " +
"Ensure the Foundry platform is providing the x-agent-user-isolation-key and x-agent-chat-isolation-key headers, " +
"or register a custom provider that supplies fallback values for local development.");
}
if (session is not null)
{
var existingHostedContext = session.GetHostedContext();
if (existingHostedContext is null)
{
// Fresh path: the session has no hosted context yet (either freshly created here,
// or freshly loaded for a conversation_id that the platform supplied without any
// prior hosted-agent request having stamped a context). Stamp it now.
session.SetHostedContext(resolvedHostedContext);
}
else if (!string.Equals(existingHostedContext.UserId, resolvedHostedContext.UserId, StringComparison.Ordinal)
|| !string.Equals(existingHostedContext.ChatId, resolvedHostedContext.ChatId, StringComparison.Ordinal))
{
// Resume path: the persisted identity must match the live request. A mismatch
// signals either a cross-user session leak or in-process tampering of the
// persisted identity. Reject the request hard.
throw new ResponsesApiException(
new Error("hosted_session_identity_mismatch", "Hosted session identity context mismatch"),
403);
}
}
// 3. Create the SDK event stream builder
var stream = new ResponseEventStream(context, request);
@@ -0,0 +1,65 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Built-in <see cref="FoundryMemoryProvider"/> <c>stateInitializer</c> factories that derive the
/// <see cref="FoundryMemoryProviderScope"/> from the per-session <see cref="HostedSessionContext"/>
/// applied by the Foundry hosting layer.
/// </summary>
/// <remarks>
/// Pass the result of any of these helpers as the <c>stateInitializer</c> argument when constructing
/// <see cref="FoundryMemoryProvider"/>:
/// <code>
/// new FoundryMemoryProvider(client, "my-store",
/// stateInitializer: HostedFoundryMemoryProviderScopes.PerUser());
/// </code>
/// All helpers throw <see cref="InvalidOperationException"/> when
/// <see cref="HostedSessionContextExtensions.GetHostedContext"/> returns <see langword="null"/>.
/// That happens when the agent runs outside the Foundry hosting layer (e.g., a console app); in
/// that case write a custom <c>stateInitializer</c> instead of using these helpers.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class HostedFoundryMemoryProviderScopes
{
/// <summary>
/// Returns a <c>stateInitializer</c> that scopes memories per end user, using
/// <see cref="HostedSessionContext.UserId"/> as the partition key.
/// </summary>
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
public static Func<AgentSession?, FoundryMemoryProvider.State> PerUser() =>
session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).UserId));
/// <summary>
/// Returns a <c>stateInitializer</c> that scopes memories per conversation, using
/// <see cref="HostedSessionContext.ChatId"/> as the partition key. Use this when memories should
/// be visible to every participant in a shared conversation (for example, a Teams group chat).
/// </summary>
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
public static Func<AgentSession?, FoundryMemoryProvider.State> PerChat() =>
session => new FoundryMemoryProvider.State(new FoundryMemoryProviderScope(GetRequiredHostedContext(session).ChatId));
/// <summary>
/// Returns a <c>stateInitializer</c> that scopes memories per (user, chat) pair, using
/// <c>"{UserId}:{ChatId}"</c> as the partition key. Use this when memories should be visible
/// only to the same user within the same conversation.
/// </summary>
/// <returns>A delegate suitable for the <c>stateInitializer</c> argument of <see cref="FoundryMemoryProvider"/>.</returns>
public static Func<AgentSession?, FoundryMemoryProvider.State> PerUserAndChat() =>
session =>
{
var ctx = GetRequiredHostedContext(session);
return new FoundryMemoryProvider.State(new FoundryMemoryProviderScope($"{ctx.UserId}:{ctx.ChatId}"));
};
private static HostedSessionContext GetRequiredHostedContext(AgentSession? session) =>
session?.GetHostedContext()
?? throw new InvalidOperationException(
$"{nameof(HostedSessionContext)} was not provided by the hosting layer. " +
$"The {nameof(HostedFoundryMemoryProviderScopes)} helpers require the agent to be hosted via the Foundry hosting layer. " +
"If running outside a hosted Foundry container, supply a custom stateInitializer to FoundryMemoryProvider instead.");
}
@@ -0,0 +1,88 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Azure.AI.Projects;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Dependency-injection helpers that register a <see cref="FoundryMemoryProvider"/> wired with a
/// <see cref="HostedFoundryMemoryProviderScopes"/> strategy.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class HostedFoundryMemoryProviderServiceCollectionExtensions
{
/// <summary>
/// Registers a singleton <see cref="FoundryMemoryProvider"/> wired to the supplied
/// <see cref="AIProjectClient"/> and the supplied <paramref name="stateInitializer"/>.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="client">The <see cref="AIProjectClient"/> used to talk to Foundry Memory.</param>
/// <param name="memoryStoreName">The name of the memory store in Microsoft Foundry.</param>
/// <param name="stateInitializer">
/// Strategy that selects the per-session <see cref="FoundryMemoryProviderScope"/>. When
/// <see langword="null"/>, the extension uses <see cref="HostedFoundryMemoryProviderScopes.PerUser"/>.
/// Pass any other helper (or a custom delegate) to override.
/// </param>
/// <param name="options">Optional <see cref="FoundryMemoryProviderOptions"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
public static IServiceCollection AddHostedFoundryMemoryProvider(
this IServiceCollection services,
AIProjectClient client,
string memoryStoreName,
Func<AgentSession?, FoundryMemoryProvider.State>? stateInitializer = null,
FoundryMemoryProviderOptions? options = null)
{
Throw.IfNull(services);
Throw.IfNull(client);
Throw.IfNullOrWhitespace(memoryStoreName);
var initializer = stateInitializer ?? HostedFoundryMemoryProviderScopes.PerUser();
services.AddSingleton(sp => new FoundryMemoryProvider(
client,
memoryStoreName,
initializer,
options,
sp.GetService<ILoggerFactory>()));
return services;
}
/// <summary>
/// Registers a singleton <see cref="FoundryMemoryProvider"/> that resolves its
/// <see cref="AIProjectClient"/> from <see cref="IServiceProvider"/> at construction time.
/// Use this overload when an <see cref="AIProjectClient"/> is already registered with the
/// service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="memoryStoreName">The name of the memory store in Microsoft Foundry.</param>
/// <param name="stateInitializer">
/// Strategy that selects the per-session <see cref="FoundryMemoryProviderScope"/>. When
/// <see langword="null"/>, the extension uses <see cref="HostedFoundryMemoryProviderScopes.PerUser"/>.
/// Pass any other helper (or a custom delegate) to override.
/// </param>
/// <param name="options">Optional <see cref="FoundryMemoryProviderOptions"/>.</param>
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
public static IServiceCollection AddHostedFoundryMemoryProvider(
this IServiceCollection services,
string memoryStoreName,
Func<AgentSession?, FoundryMemoryProvider.State>? stateInitializer = null,
FoundryMemoryProviderOptions? options = null)
{
Throw.IfNull(services);
Throw.IfNullOrWhitespace(memoryStoreName);
var initializer = stateInitializer ?? HostedFoundryMemoryProviderScopes.PerUser();
services.AddSingleton(sp => new FoundryMemoryProvider(
sp.GetRequiredService<AIProjectClient>(),
memoryStoreName,
initializer,
options,
sp.GetService<ILoggerFactory>()));
return services;
}
}
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Captures the per-session identity values produced by a <see cref="HostedSessionIsolationKeyProvider"/>
/// when a Foundry hosted agent processes a request.
/// </summary>
/// <remarks>
/// <para>
/// The <see cref="UserId"/> partitions data that belongs to the individual who initiated the request
/// (e.g., personal memory, per-user preferences). The <see cref="ChatId"/> partitions data that belongs
/// to the conversation (e.g., conversation history, turn state). Both values are opaque strings whose
/// meaning is determined by the active <see cref="HostedSessionIsolationKeyProvider"/>.
/// </para>
/// <para>
/// Instances are constructed by the hosting layer from the platform-provided
/// <c>IsolationContext</c> headers and stored on the session via
/// <see cref="HostedSessionContextExtensions.SetHostedContext"/>. Consumers (typically
/// <see cref="AIContextProvider"/> implementations) read the values through
/// <see cref="HostedSessionContextExtensions.GetHostedContext"/>.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class HostedSessionContext
{
/// <summary>
/// Initializes a new instance of the <see cref="HostedSessionContext"/> class.
/// </summary>
/// <param name="userId">The opaque user identity for this hosted session. Must not be null or whitespace.</param>
/// <param name="chatId">The opaque chat (conversation) identity for this hosted session. Must not be null or whitespace.</param>
/// <exception cref="System.ArgumentException">Thrown when <paramref name="userId"/> or <paramref name="chatId"/> is null or whitespace.</exception>
public HostedSessionContext(string userId, string chatId)
{
this.UserId = Throw.IfNullOrWhitespace(userId);
this.ChatId = Throw.IfNullOrWhitespace(chatId);
}
/// <summary>
/// Gets the opaque user identity for this hosted session.
/// </summary>
/// <remarks>
/// Stable for a given user across sessions. In production this is sourced from the
/// <c>x-agent-user-isolation-key</c> platform header.
/// </remarks>
public string UserId { get; }
/// <summary>
/// Gets the opaque chat (conversation) identity for this hosted session.
/// </summary>
/// <remarks>
/// In a 1:1 user-to-agent chat this typically equals <see cref="UserId"/>. In shared-surface
/// scenarios (e.g., a Teams group chat) it represents the common partition all participants
/// write to. In production this is sourced from the <c>x-agent-chat-isolation-key</c> platform header.
/// </remarks>
public string ChatId { get; }
}
@@ -0,0 +1,81 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Extension methods for reading and writing the <see cref="HostedSessionContext"/> associated
/// with an <see cref="AgentSession"/> in a Foundry hosted agent.
/// </summary>
/// <remarks>
/// The hosted session context is written exactly once by the hosting layer when a session is created,
/// and is validated against the live request on every subsequent invocation. The <see cref="SetHostedContext"/>
/// method is intentionally <see langword="internal"/> so that only the hosting layer can establish the
/// identity values; consumers (such as <see cref="AIContextProvider"/> implementations) read the values
/// through the public <see cref="GetHostedContext"/> accessor.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class HostedSessionContextExtensions
{
/// <summary>
/// The well-known <see cref="AgentSessionStateBag"/> key used to store the
/// <see cref="HostedSessionContext"/> on a session.
/// </summary>
/// <remarks>
/// Exposed as a constant so consumers can correlate persisted state across processes.
/// External code must not write to this key directly; use <see cref="SetHostedContext"/> from the
/// hosting assembly instead.
/// </remarks>
public const string StateKey = "Microsoft.Agents.AI.Foundry.Hosting.HostedSessionContext";
/// <summary>
/// Gets the <see cref="HostedSessionContext"/> previously written by the hosting layer
/// for this session, if any.
/// </summary>
/// <param name="session">The session to read from.</param>
/// <returns>
/// The <see cref="HostedSessionContext"/> for the session, or <see langword="null"/> when the
/// session was not produced by a hosted agent (or the value has not yet been written).
/// </returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="session"/> is <see langword="null"/>.</exception>
public static HostedSessionContext? GetHostedContext(this AgentSession session)
{
Throw.IfNull(session);
return session.StateBag.TryGetValue<HostedSessionContext>(StateKey, out var context, HostedSessionJsonUtilities.DefaultOptions)
? context
: null;
}
/// <summary>
/// Writes the <see cref="HostedSessionContext"/> for this session.
/// </summary>
/// <param name="session">The session to write to.</param>
/// <param name="context">The hosted session context to associate with <paramref name="session"/>.</param>
/// <remarks>
/// Internal to the hosting assembly. Consumers must not invoke this method directly; the hosting
/// layer is the single writer and uses validation against the live request to detect any tampering
/// that does occur via lower-level APIs. Throws when a context has already been written for this
/// session to enforce the write-once contract.
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="session"/> or <paramref name="context"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">Thrown when this session already carries a <see cref="HostedSessionContext"/>.</exception>
internal static void SetHostedContext(this AgentSession session, HostedSessionContext context)
{
Throw.IfNull(session);
Throw.IfNull(context);
if (session.StateBag.TryGetValue<HostedSessionContext>(StateKey, out _, HostedSessionJsonUtilities.DefaultOptions))
{
throw new InvalidOperationException(
$"A {nameof(HostedSessionContext)} has already been written to this session. " +
"The hosted session identity is write-once; resumed sessions must validate against the existing context, not overwrite it.");
}
session.StateBag.SetValue(StateKey, context, HostedSessionJsonUtilities.DefaultOptions);
}
}
@@ -0,0 +1,54 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Resolves the per-request <see cref="HostedSessionContext"/> for a Foundry hosted agent.
/// </summary>
/// <remarks>
/// <para>
/// Implementations are invoked once per incoming Responses API request. The returned
/// <see cref="HostedSessionContext"/> establishes the identity of a freshly created session and
/// is validated against the live request on every subsequent invocation that resumes the same session.
/// </para>
/// <para>
/// The default implementation registered when no custom <see cref="HostedSessionIsolationKeyProvider"/>
/// is present in DI maps the platform-injected <c>x-agent-user-isolation-key</c> and
/// <c>x-agent-chat-isolation-key</c> headers via <see cref="ResponseContext.Isolation"/>. Hosting samples and contributor-only environments
/// can register an alternate implementation in DI to provide values when the platform headers are absent
/// (e.g., during local Docker debugging).
/// </para>
/// <para>
/// Implementations must return a <see cref="HostedSessionContext"/> whose <see cref="HostedSessionContext.UserId"/>
/// and <see cref="HostedSessionContext.ChatId"/> are both non-null and non-whitespace. Returning either as null
/// (or throwing from <see cref="GetKeysAsync"/>) is treated as a configuration error and surfaces as a
/// 500 from the hosting layer.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public abstract class HostedSessionIsolationKeyProvider
{
/// <summary>
/// Resolves the <see cref="HostedSessionContext"/> for the supplied request.
/// </summary>
/// <param name="context">The per-request <see cref="ResponseContext"/> from the Azure AI Responses Server SDK.</param>
/// <param name="request">The <see cref="CreateResponse"/> describing the incoming request.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A <see cref="HostedSessionContext"/> with non-null <see cref="HostedSessionContext.UserId"/> and
/// <see cref="HostedSessionContext.ChatId"/>, or <see langword="null"/> when the implementation cannot
/// produce identity keys for the current request. A <see langword="null"/> result is treated as a
/// configuration error by the hosting layer and surfaces as 500.
/// </returns>
public abstract ValueTask<HostedSessionContext?> GetKeysAsync(
ResponseContext context,
CreateResponse request,
CancellationToken cancellationToken);
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// JSON serialization utilities for hosted session identity types.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
internal static class HostedSessionJsonUtilities
{
/// <summary>
/// Default JSON serializer options for hosted session state.
/// </summary>
public static JsonSerializerOptions DefaultOptions { get; } = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
TypeInfoResolver = HostedSessionJsonContext.Default
};
}
/// <summary>
/// Source-generated JSON serialization context for hosted session identity types.
/// </summary>
[JsonSourceGenerationOptions(
JsonSerializerDefaults.General,
UseStringEnumConverter = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
WriteIndented = false)]
[JsonSerializable(typeof(HostedSessionContext))]
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
internal partial class HostedSessionJsonContext : JsonSerializerContext;
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Default <see cref="HostedSessionIsolationKeyProvider"/> implementation that maps the platform-injected
/// <c>x-agent-user-isolation-key</c> and <c>x-agent-chat-isolation-key</c> headers from
/// <see cref="ResponseContext.Isolation"/> into a <see cref="HostedSessionContext"/>.
/// </summary>
/// <remarks>
/// This is the implementation used in production Foundry hosted environments. When running locally
/// outside the platform, both isolation keys are <see langword="null"/>, which causes
/// <see cref="GetKeysAsync"/> to return <see langword="null"/>. The hosting layer treats a null
/// result as a configuration error and surfaces it as a 500 from the request. Local development
/// should register an alternate <see cref="HostedSessionIsolationKeyProvider"/> implementation
/// that provides fallback values for the missing headers.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
internal sealed class PlatformHostedSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider
{
/// <inheritdoc />
public override ValueTask<HostedSessionContext?> GetKeysAsync(
ResponseContext context,
CreateResponse request,
CancellationToken cancellationToken)
{
var userKey = context?.Isolation?.UserIsolationKey;
var chatKey = context?.Isolation?.ChatIsolationKey;
if (string.IsNullOrWhiteSpace(userKey) || string.IsNullOrWhiteSpace(chatKey))
{
return new ValueTask<HostedSessionContext?>((HostedSessionContext?)null);
}
return new ValueTask<HostedSessionContext?>(new HostedSessionContext(userKey!, chatKey!));
}
}