mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
* .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:
committed by
GitHub
Unverified
parent
97eaef029e
commit
ad95f2f2fa
@@ -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.");
|
||||
}
|
||||
+88
@@ -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;
|
||||
+44
@@ -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!));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user