Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs
T
Roger BarretoandGitHub ad95f2f2fa .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.
2026-05-15 05:42:12 +00:00

433 lines
19 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Threading;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// A <see cref="ResponseHandler"/> implementation that bridges the Azure AI Responses Server SDK
/// with agent-framework <see cref="AIAgent"/> instances, enabling agent-framework agents and workflows
/// to be hosted as Azure Foundry Hosted Agents.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public class AgentFrameworkResponseHandler : ResponseHandler
{
private readonly IServiceProvider _serviceProvider;
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.
/// </summary>
/// <param name="serviceProvider">The service provider for resolving agents.</param>
/// <param name="logger">The logger instance.</param>
/// <param name="toolboxService">Optional Foundry Toolbox service providing MCP tools.</param>
public AgentFrameworkResponseHandler(
IServiceProvider serviceProvider,
ILogger<AgentFrameworkResponseHandler> logger,
FoundryToolboxService? toolboxService = null)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
ArgumentNullException.ThrowIfNull(logger);
this._serviceProvider = serviceProvider;
this._logger = logger;
this._toolboxService = toolboxService;
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ResponseStreamEvent> CreateAsync(
CreateResponse request,
ResponseContext context,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// 1. Resolve agent
var agent = this.ResolveAgent(request);
var sessionStore = this.ResolveSessionStore(request);
// 2. Load or create a new session from the interaction
var sessionConversationId = request.GetConversationId();
var chatClientAgent = agent.GetService<ChatClientAgent>();
AgentSession? session = !string.IsNullOrWhiteSpace(sessionConversationId)
? await sessionStore.GetSessionAsync(agent, sessionConversationId, cancellationToken).ConfigureAwait(false)
: chatClientAgent is not null
? 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);
// 3. Emit lifecycle events
yield return stream.EmitCreated();
yield return stream.EmitInProgress();
// 4. Convert input: history + current input → ChatMessage[]
var messages = new List<ChatMessage>();
// Load conversation history only for fresh sessions. When a session already exists
// (e.g. resuming a workflow paused at an external-input port), the workflow's
// checkpointed state already contains the prior turns' messages — replaying history
// would re-drive completed actions and break HITL resume semantics.
var isResume = !string.IsNullOrWhiteSpace(sessionConversationId)
&& session?.StateBag?.Count > 0;
if (!isResume)
{
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
if (history.Count > 0)
{
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history, session?.StateBag));
}
}
// Load and convert current input items
var inputItems = await context.GetInputItemsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
if (inputItems.Count > 0)
{
messages.AddRange(InputConverter.ConvertItemsToMessages(inputItems, session?.StateBag));
}
else
{
// Fall back to raw request input
messages.AddRange(InputConverter.ConvertInputToMessages(request, session?.StateBag));
}
// 5. Build chat options
var chatOptions = InputConverter.ConvertToChatOptions(request);
chatOptions.Instructions = request.Instructions;
// Inject Foundry Toolbox tools when the toolbox service is available.
//
// Two sources are considered:
// 1. Pre-registered toolboxes (via AddFoundryToolboxes) — always appended.
// 2. Per-request markers embedded in request.Tools (HostedMcpToolboxAITool)
// whose ServerAddress scheme is "foundry-toolbox://". Strict mode rejects
// unknown names; otherwise a lazy MCP client is opened and cached.
//
// Each toolbox's tools are only appended once per request, even if it appears
// in both the pre-registered list and the per-request markers.
if (this._toolboxService is not null)
{
List<AITool>? toolsToAdd = null;
if (this._toolboxService.Tools.Count > 0)
{
toolsToAdd = [.. this._toolboxService.Tools];
}
var markers = InputConverter.ReadMcpToolboxMarkers(request);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string? resolutionError = null;
foreach (var (name, version) in markers)
{
if (!seen.Add(name))
{
continue;
}
IReadOnlyList<AITool>? toolboxTools = null;
try
{
toolboxTools = await this._toolboxService
.GetToolboxToolsAsync(name, version, cancellationToken)
.ConfigureAwait(false);
}
catch (InvalidOperationException ex)
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
this._logger.LogWarning(
ex,
"Foundry toolbox '{ToolboxName}' could not be resolved for response {ResponseId}.",
name,
context.ResponseId);
}
resolutionError = ex.Message;
break;
}
toolsToAdd ??= [];
foreach (var t in toolboxTools)
{
if (!toolsToAdd.Contains(t))
{
toolsToAdd.Add(t);
}
}
}
if (resolutionError is not null)
{
yield return stream.EmitFailed(ResponseErrorCode.ServerError, resolutionError);
yield break;
}
if (toolsToAdd?.Count > 0)
{
chatOptions.Tools = [.. chatOptions.Tools ?? [], .. toolsToAdd];
}
}
var options = new ChatClientAgentRunOptions(chatOptions);
// 6. Set up consent context for -32006 OAuth consent interception.
// We create a linked CTS so the consent-aware tool wrapper can cancel the agent
// run mid-loop when a -32006 error is returned by the proxy. The RequestConsentState
// is a shared mutable object that flows via AsyncLocal to the tool wrapper.
using var consentCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var consentState = new RequestConsentState { CancellationSource = consentCts };
McpConsentContext.Current.Value = consentState;
// 7. Run the agent and convert output
// NOTE: C# forbids 'yield return' inside a try block that has a catch clause,
// and inside catch blocks. We use a flag to defer the yield to outside the try/catch.
bool emittedTerminal = false;
var enumerator = OutputConverter.ConvertUpdatesToEventsAsync(
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
stream,
session?.StateBag,
cancellationToken).GetAsyncEnumerator(cancellationToken);
try
{
while (true)
{
bool shutdownDetected = false;
McpConsentInfo? consentInfo = null;
ResponseStreamEvent? failedEvent = null;
ResponseStreamEvent? evt = null;
try
{
if (!await enumerator.MoveNextAsync().ConfigureAwait(false))
{
break;
}
evt = enumerator.Current;
}
catch (OperationCanceledException) when (!emittedTerminal && consentState.Pending is not null)
{
// -32006 consent error: the tool wrapper cancelled consentCts and stored consent info.
consentInfo = consentState.Pending;
}
catch (OperationCanceledException) when (context.IsShutdownRequested && !emittedTerminal)
{
shutdownDetected = true;
}
catch (Exception ex) when (ex is not OperationCanceledException && !emittedTerminal)
{
// Catch agent execution errors and emit a proper failed event
// with the real error message instead of letting the SDK emit
// a generic "An internal server error occurred."
if (this._logger.IsEnabled(LogLevel.Error))
{
this._logger.LogError(ex, "Agent execution failed for response {ResponseId}.", context.ResponseId);
}
failedEvent = stream.EmitFailed(
ResponseErrorCode.ServerError,
ex.Message);
}
if (consentInfo is not null)
{
// Emit mcp_approval_request output item + incomplete for the consent URL.
foreach (var approvalEvent in stream.OutputItemMcpApprovalRequest(
consentInfo.ToolboxName,
consentInfo.ToolName,
consentInfo.ConsentUrl))
{
yield return approvalEvent;
}
yield return stream.EmitIncomplete(reason: null);
yield break;
}
if (failedEvent is not null)
{
yield return failedEvent;
yield break;
}
if (shutdownDetected)
{
// Server is shutting down — emit incomplete so clients can resume
this._logger.LogInformation("Shutdown detected, emitting incomplete response.");
yield return stream.EmitIncomplete();
yield break;
}
// yield is in the outer try (finally-only) — allowed by C#
yield return evt!;
if (evt is ResponseCompletedEvent or ResponseFailedEvent or ResponseIncompleteEvent)
{
emittedTerminal = true;
}
}
}
finally
{
await enumerator.DisposeAsync().ConfigureAwait(false);
// Persist session after streaming completes (successful or not)
if (session is not null && !string.IsNullOrWhiteSpace(sessionConversationId))
{
await sessionStore.SaveSessionAsync(agent, sessionConversationId, session, cancellationToken).ConfigureAwait(false);
}
}
}
/// <summary>
/// Resolves an <see cref="AIAgent"/> from the request.
/// Tries <c>agent.name</c> first, then falls back to <c>metadata["entity_id"]</c>.
/// If neither is present, attempts to resolve a default (non-keyed) <see cref="AIAgent"/>.
/// </summary>
private AIAgent ResolveAgent(CreateResponse request)
{
var agentName = GetAgentName(request);
if (!string.IsNullOrEmpty(agentName))
{
var agent = this._serviceProvider.GetKeyedService<AIAgent>(agentName);
if (agent is not null)
{
FoundryHostingExtensions.TryApplyUserAgent(agent);
return FoundryHostingExtensions.ApplyOpenTelemetry(agent);
}
if (this._logger.IsEnabled(LogLevel.Warning))
{
this._logger.LogWarning("Agent '{AgentName}' not found in keyed services. Attempting default resolution.", agentName);
}
}
// Try non-keyed default
var defaultAgent = this._serviceProvider.GetService<AIAgent>();
if (defaultAgent is not null)
{
FoundryHostingExtensions.TryApplyUserAgent(defaultAgent);
return FoundryHostingExtensions.ApplyOpenTelemetry(defaultAgent);
}
var errorMessage = string.IsNullOrEmpty(agentName)
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AIAgent is registered."
: $"Agent '{agentName}' not found. Ensure it is registered via AddFoundryResponses(services, agent) or services.AddKeyedSingleton<AIAgent>(\"{agentName}\", ...).";
throw new InvalidOperationException(errorMessage);
}
/// <summary>
/// Resolves an <see cref="AIAgent"/> from the request.
/// Tries <c>agent.name</c> first, then falls back to <c>metadata["entity_id"]</c>.
/// If neither is present, attempts to resolve a default (non-keyed) <see cref="AIAgent"/>.
/// </summary>
private AgentSessionStore ResolveSessionStore(CreateResponse request)
{
var agentName = GetAgentName(request);
if (!string.IsNullOrEmpty(agentName))
{
var sessionStore = this._serviceProvider.GetKeyedService<AgentSessionStore>(agentName);
if (sessionStore is not null)
{
return sessionStore;
}
if (this._logger.IsEnabled(LogLevel.Warning))
{
this._logger.LogWarning("SessionStore for agent '{AgentName}' not found in keyed services. Attempting default resolution.", agentName);
}
}
// Try non-keyed default
var defaultSessionStore = this._serviceProvider.GetService<AgentSessionStore>();
if (defaultSessionStore is not null)
{
return defaultSessionStore;
}
var errorMessage = string.IsNullOrEmpty(agentName)
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AgentSessionStore is registered."
: $"AgentSessionStore for agent '{agentName}' not found. Ensure it is registered via AddFoundryResponses(services, agent, agentSessionStore) or services.AddKeyedSingleton<AgentSessionStore>(\"{agentName}\", ...).";
throw new InvalidOperationException(errorMessage);
}
private static string? GetAgentName(CreateResponse request)
{
// Try agent.name from AgentReference
var agentName = request.AgentReference?.Name;
// Fall back to "model" field (OpenAI clients send the agent name as the model)
if (string.IsNullOrEmpty(agentName))
{
agentName = request.Model;
}
// Fall back to metadata["entity_id"]
if (string.IsNullOrEmpty(agentName) && request.Metadata?.AdditionalProperties is not null)
{
request.Metadata.AdditionalProperties.TryGetValue("entity_id", out agentName);
}
return agentName;
}
}