// 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;
///
/// A implementation that bridges the Azure AI Responses Server SDK
/// with agent-framework instances, enabling agent-framework agents and workflows
/// to be hosted as Azure Foundry Hosted Agents.
///
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public class AgentFrameworkResponseHandler : ResponseHandler
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger _logger;
private readonly FoundryToolboxService? _toolboxService;
///
/// Initializes a new instance of the class
/// that resolves agents from keyed DI services.
///
/// The service provider for resolving agents.
/// The logger instance.
/// Optional Foundry Toolbox service providing MCP tools.
public AgentFrameworkResponseHandler(
IServiceProvider serviceProvider,
ILogger logger,
FoundryToolboxService? toolboxService = null)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
ArgumentNullException.ThrowIfNull(logger);
this._serviceProvider = serviceProvider;
this._logger = logger;
this._toolboxService = toolboxService;
}
///
public override async IAsyncEnumerable 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();
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);
// 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();
// 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? toolsToAdd = null;
if (this._toolboxService.Tools.Count > 0)
{
toolsToAdd = [.. this._toolboxService.Tools];
}
var markers = InputConverter.ReadMcpToolboxMarkers(request);
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
string? resolutionError = null;
foreach (var (name, version) in markers)
{
if (!seen.Add(name))
{
continue;
}
IReadOnlyList? 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);
}
}
}
///
/// Resolves an from the request.
/// Tries agent.name first, then falls back to metadata["entity_id"].
/// If neither is present, attempts to resolve a default (non-keyed) .
///
private AIAgent ResolveAgent(CreateResponse request)
{
var agentName = GetAgentName(request);
if (!string.IsNullOrEmpty(agentName))
{
var agent = this._serviceProvider.GetKeyedService(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();
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(\"{agentName}\", ...).";
throw new InvalidOperationException(errorMessage);
}
///
/// Resolves an from the request.
/// Tries agent.name first, then falls back to metadata["entity_id"].
/// If neither is present, attempts to resolve a default (non-keyed) .
///
private AgentSessionStore ResolveSessionStore(CreateResponse request)
{
var agentName = GetAgentName(request);
if (!string.IsNullOrEmpty(agentName))
{
var sessionStore = this._serviceProvider.GetKeyedService(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();
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(\"{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;
}
}