Add Azure AI Foundry Responses hosting adapter

Implement Microsoft.Agents.AI.Hosting.AzureAIResponses to host agent-framework
AIAgents and workflows within Azure Foundry as hosted agents via the
Azure.AI.AgentServer.Responses SDK.

- AgentFrameworkResponseHandler: bridges ResponseHandler to AIAgent execution
- InputConverter: converts Responses API inputs/history to MEAI ChatMessage
- OutputConverter: converts agent response updates to SSE event stream
- ServiceCollectionExtensions: DI registration helpers
- 336 unit tests across net8.0/net9.0/net10.0 (112 per TFM)
- ResponseStreamValidator: SSE protocol validation tool for samples
- FoundryResponsesHosting sample app

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
alliscode
2026-03-31 14:57:00 -07:00
Unverified
parent 3fc1d00026
commit 0b1ed03cd0
19 changed files with 5416 additions and 0 deletions
@@ -0,0 +1,186 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Hosting.AzureAIResponses;
/// <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>
public class AgentFrameworkResponseHandler : ResponseHandler
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<AgentFrameworkResponseHandler> _logger;
/// <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>
public AgentFrameworkResponseHandler(
IServiceProvider serviceProvider,
ILogger<AgentFrameworkResponseHandler> logger)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
ArgumentNullException.ThrowIfNull(logger);
this._serviceProvider = serviceProvider;
this._logger = logger;
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ResponseStreamEvent> CreateAsync(
CreateResponse request,
ResponseContext context,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// 1. Resolve agent
var agent = this.ResolveAgent(request);
// 2. 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 if available
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
if (history.Count > 0)
{
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history));
}
// Load and convert current input items
var inputItems = await context.GetInputItemsAsync(cancellationToken).ConfigureAwait(false);
if (inputItems.Count > 0)
{
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(inputItems));
}
else
{
// Fall back to raw request input
messages.AddRange(InputConverter.ConvertInputToMessages(request));
}
// 5. Build chat options
var chatOptions = InputConverter.ConvertToChatOptions(request);
chatOptions.Instructions = request.Instructions;
var options = new ChatClientAgentRunOptions(chatOptions);
// 6. 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, options: options, cancellationToken: cancellationToken),
stream,
cancellationToken).GetAsyncEnumerator(cancellationToken);
try
{
while (true)
{
bool shutdownDetected = false;
ResponseStreamEvent? evt = null;
try
{
if (!await enumerator.MoveNextAsync().ConfigureAwait(false))
{
break;
}
evt = enumerator.Current;
}
catch (OperationCanceledException) when (context.IsShutdownRequested && !emittedTerminal)
{
shutdownDetected = true;
}
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);
}
}
/// <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)
{
return agent;
}
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)
{
return 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 AddAIAgent(\"{agentName}\", ...) or as a default AIAgent.";
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;
}
}
@@ -0,0 +1,296 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Extensions.AI;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
namespace Microsoft.Agents.AI.Hosting.AzureAIResponses;
/// <summary>
/// Converts Responses Server SDK input types to agent-framework <see cref="ChatMessage"/> types.
/// </summary>
internal static class InputConverter
{
/// <summary>
/// Converts the SDK <see cref="CreateResponse"/> request input items into a list of <see cref="ChatMessage"/>.
/// </summary>
/// <param name="request">The create response request from the SDK.</param>
/// <returns>A list of chat messages representing the request input.</returns>
public static List<ChatMessage> ConvertInputToMessages(CreateResponse request)
{
var messages = new List<ChatMessage>();
foreach (var item in request.GetInputExpanded())
{
var message = ConvertInputItemToMessage(item);
if (message is not null)
{
messages.Add(message);
}
}
return messages;
}
/// <summary>
/// Converts resolved SDK <see cref="OutputItem"/> history/input items into <see cref="ChatMessage"/> instances.
/// </summary>
/// <param name="items">The resolved output items from the SDK context.</param>
/// <returns>A list of chat messages.</returns>
public static List<ChatMessage> ConvertOutputItemsToMessages(IReadOnlyList<OutputItem> items)
{
var messages = new List<ChatMessage>();
foreach (var item in items)
{
var message = ConvertOutputItemToMessage(item);
if (message is not null)
{
messages.Add(message);
}
}
return messages;
}
/// <summary>
/// Creates <see cref="ChatOptions"/> from the SDK request properties.
/// </summary>
/// <param name="request">The create response request.</param>
/// <returns>A configured <see cref="ChatOptions"/> instance.</returns>
public static ChatOptions ConvertToChatOptions(CreateResponse request)
{
return new ChatOptions
{
Temperature = (float?)request.Temperature,
TopP = (float?)request.TopP,
MaxOutputTokens = (int?)request.MaxOutputTokens,
ModelId = request.Model,
};
}
private static ChatMessage? ConvertInputItemToMessage(Item item)
{
return item switch
{
ItemMessage msg => ConvertItemMessage(msg),
FunctionCallOutputItemParam funcOutput => ConvertFunctionCallOutput(funcOutput),
ItemFunctionToolCall funcCall => ConvertItemFunctionToolCall(funcCall),
ItemReferenceParam => null,
_ => null
};
}
private static ChatMessage ConvertItemMessage(ItemMessage msg)
{
var role = ConvertMessageRole(msg.Role);
var contents = new List<AIContent>();
foreach (var content in msg.GetContentExpanded())
{
switch (content)
{
case MessageContentInputTextContent textContent:
contents.Add(new MeaiTextContent(textContent.Text));
break;
case MessageContentInputImageContent imageContent:
if (imageContent.ImageUrl is not null)
{
var url = imageContent.ImageUrl.ToString();
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
contents.Add(new DataContent(url, "image/*"));
}
else
{
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
}
}
else if (!string.IsNullOrEmpty(imageContent.FileId))
{
contents.Add(new HostedFileContent(imageContent.FileId));
}
break;
case MessageContentInputFileContent fileContent:
if (fileContent.FileUrl is not null)
{
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
}
else if (!string.IsNullOrEmpty(fileContent.FileData))
{
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
}
else if (!string.IsNullOrEmpty(fileContent.FileId))
{
contents.Add(new HostedFileContent(fileContent.FileId));
}
else if (!string.IsNullOrEmpty(fileContent.Filename))
{
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
}
break;
}
}
if (contents.Count == 0)
{
contents.Add(new MeaiTextContent(string.Empty));
}
return new ChatMessage(role, contents);
}
private static ChatMessage ConvertFunctionCallOutput(FunctionCallOutputItemParam funcOutput)
{
var output = funcOutput.Output?.ToString() ?? string.Empty;
return new ChatMessage(
ChatRole.Tool,
[new FunctionResultContent(funcOutput.CallId, output)]);
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK input.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK input.")]
private static ChatMessage ConvertItemFunctionToolCall(ItemFunctionToolCall funcCall)
{
IDictionary<string, object?>? arguments = null;
if (funcCall.Arguments is not null)
{
try
{
arguments = JsonSerializer.Deserialize<Dictionary<string, object?>>(funcCall.Arguments);
}
catch (JsonException)
{
arguments = new Dictionary<string, object?> { ["_raw"] = funcCall.Arguments };
}
}
return new ChatMessage(
ChatRole.Assistant,
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
}
private static ChatMessage? ConvertOutputItemToMessage(OutputItem item)
{
return item switch
{
OutputItemMessage msg => ConvertOutputItemMessageToChat(msg),
OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall),
FunctionToolCallOutputResource funcOutput => ConvertFunctionToolCallOutputResource(funcOutput),
OutputItemReasoningItem => null,
_ => null
};
}
private static ChatMessage ConvertOutputItemMessageToChat(OutputItemMessage msg)
{
var role = ConvertMessageRole(msg.Role);
var contents = new List<AIContent>();
foreach (var content in msg.Content)
{
switch (content)
{
case MessageContentInputTextContent textContent:
contents.Add(new MeaiTextContent(textContent.Text));
break;
case MessageContentOutputTextContent textContent:
contents.Add(new MeaiTextContent(textContent.Text));
break;
case MessageContentRefusalContent refusal:
contents.Add(new MeaiTextContent($"[Refusal: {refusal.Refusal}]"));
break;
case MessageContentInputImageContent imageContent:
if (imageContent.ImageUrl is not null)
{
var url = imageContent.ImageUrl.ToString();
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
contents.Add(new DataContent(url, "image/*"));
}
else
{
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
}
}
else if (!string.IsNullOrEmpty(imageContent.FileId))
{
contents.Add(new HostedFileContent(imageContent.FileId));
}
break;
case MessageContentInputFileContent fileContent:
if (fileContent.FileUrl is not null)
{
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
}
else if (!string.IsNullOrEmpty(fileContent.FileData))
{
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
}
else if (!string.IsNullOrEmpty(fileContent.FileId))
{
contents.Add(new HostedFileContent(fileContent.FileId));
}
else if (!string.IsNullOrEmpty(fileContent.Filename))
{
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
}
break;
}
}
if (contents.Count == 0)
{
contents.Add(new MeaiTextContent(string.Empty));
}
return new ChatMessage(role, contents);
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK output history.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK output history.")]
private static ChatMessage ConvertOutputItemFunctionCall(OutputItemFunctionToolCall funcCall)
{
IDictionary<string, object?>? arguments = null;
if (funcCall.Arguments is not null)
{
try
{
arguments = JsonSerializer.Deserialize<Dictionary<string, object?>>(funcCall.Arguments);
}
catch (JsonException)
{
arguments = new Dictionary<string, object?> { ["_raw"] = funcCall.Arguments };
}
}
return new ChatMessage(
ChatRole.Assistant,
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
}
private static ChatMessage ConvertFunctionToolCallOutputResource(FunctionToolCallOutputResource funcOutput)
{
return new ChatMessage(
ChatRole.Tool,
[new FunctionResultContent(funcOutput.CallId, funcOutput.Output)]);
}
private static ChatRole ConvertMessageRole(MessageRole role)
{
return role switch
{
MessageRole.User => ChatRole.User,
MessageRole.Assistant => ChatRole.Assistant,
MessageRole.System => ChatRole.System,
MessageRole.Developer => new ChatRole("developer"),
_ => ChatRole.User
};
}
}
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Microsoft.Agents.AI.Hosting.AzureAIResponses</RootNamespace>
<VersionSuffix>alpha</VersionSuffix>
<NoWarn>$(NoWarn);MEAI001;NU1903</NoWarn>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.Responses" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.AzureAIResponses.UnitTests" />
</ItemGroup>
</Project>
@@ -0,0 +1,346 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Azure.AI.AgentServer.Responses;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
namespace Microsoft.Agents.AI.Hosting.AzureAIResponses;
/// <summary>
/// Converts agent-framework <see cref="AgentResponseUpdate"/> streams into
/// Responses Server SDK <see cref="ResponseStreamEvent"/> sequences using the
/// <see cref="ResponseEventStream"/> builder pattern.
/// </summary>
internal static class OutputConverter
{
/// <summary>
/// Converts a stream of <see cref="AgentResponseUpdate"/> into a stream of
/// <see cref="ResponseStreamEvent"/> using the SDK builder pattern.
/// </summary>
/// <param name="updates">The agent response updates to convert.</param>
/// <param name="stream">The SDK event stream builder.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of SDK response stream events (excluding lifecycle events).</returns>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call arguments dictionary.")]
public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEventsAsync(
IAsyncEnumerable<AgentResponseUpdate> updates,
ResponseEventStream stream,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ResponseUsage? accumulatedUsage = null;
OutputItemMessageBuilder? currentMessageBuilder = null;
TextContentBuilder? currentTextBuilder = null;
StringBuilder? accumulatedText = null;
string? previousMessageId = null;
bool hasTerminalEvent = false;
var executorItemIds = new Dictionary<string, string>();
await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
{
cancellationToken.ThrowIfCancellationRequested();
// Handle workflow events from RawRepresentation
if (update.RawRepresentation is WorkflowEvent workflowEvent)
{
// Close any open message builder before emitting workflow items
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
previousMessageId = null;
foreach (var evt in EmitWorkflowEvent(stream, workflowEvent, executorItemIds))
{
yield return evt;
}
continue;
}
foreach (var content in update.Contents)
{
switch (content)
{
case MeaiTextContent textContent:
{
if (!IsSameMessage(update.MessageId, previousMessageId) && currentMessageBuilder is not null)
{
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
}
previousMessageId = update.MessageId;
if (currentMessageBuilder is null)
{
currentMessageBuilder = stream.AddOutputItemMessage();
yield return currentMessageBuilder.EmitAdded();
currentTextBuilder = currentMessageBuilder.AddTextContent();
yield return currentTextBuilder.EmitAdded();
accumulatedText = new StringBuilder();
}
if (textContent.Text is { Length: > 0 })
{
accumulatedText!.Append(textContent.Text);
yield return currentTextBuilder!.EmitDelta(textContent.Text);
}
break;
}
case FunctionCallContent funcCall:
{
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
previousMessageId = null;
var callId = funcCall.CallId ?? Guid.NewGuid().ToString("N");
var funcBuilder = stream.AddOutputItemFunctionCall(funcCall.Name, callId);
yield return funcBuilder.EmitAdded();
var arguments = funcCall.Arguments is not null
? JsonSerializer.Serialize(funcCall.Arguments)
: "{}";
yield return funcBuilder.EmitArgumentsDelta(arguments);
yield return funcBuilder.EmitArgumentsDone(arguments);
yield return funcBuilder.EmitDone();
break;
}
case TextReasoningContent reasoningContent:
{
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
previousMessageId = null;
var reasoningBuilder = stream.AddOutputItemReasoningItem();
yield return reasoningBuilder.EmitAdded();
var summaryPart = reasoningBuilder.AddSummaryPart();
yield return summaryPart.EmitAdded();
var text = reasoningContent.Text ?? string.Empty;
yield return summaryPart.EmitTextDelta(text);
yield return summaryPart.EmitTextDone(text);
yield return summaryPart.EmitDone();
reasoningBuilder.EmitSummaryPartDone(summaryPart);
yield return reasoningBuilder.EmitDone();
break;
}
case UsageContent usageContent when usageContent.Details is not null:
{
accumulatedUsage = ConvertUsage(usageContent.Details, accumulatedUsage);
break;
}
case ErrorContent errorContent:
{
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
previousMessageId = null;
hasTerminalEvent = true;
yield return stream.EmitFailed(
ResponseErrorCode.ServerError,
errorContent.Message ?? "An error occurred during agent execution.",
accumulatedUsage);
yield break;
}
case DataContent:
case UriContent:
// Image/audio/file content from agents is not currently supported
// as streaming output items in the Responses Server SDK builder pattern.
// These would need to be serialized as base64 or URL references.
break;
case FunctionResultContent:
// Function results are internal to the agent's tool-calling loop
// and are not emitted as output items in the response stream.
break;
default:
break;
}
}
}
// Close any remaining open message
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
if (!hasTerminalEvent)
{
yield return stream.EmitCompleted(accumulatedUsage);
}
}
private static IEnumerable<ResponseStreamEvent> CloseCurrentMessage(
OutputItemMessageBuilder? messageBuilder,
TextContentBuilder? textBuilder,
StringBuilder? accumulatedText)
{
if (messageBuilder is null)
{
yield break;
}
if (textBuilder is not null)
{
var finalText = accumulatedText?.ToString() ?? string.Empty;
yield return textBuilder.EmitDone(finalText);
yield return messageBuilder.EmitContentDone(textBuilder);
}
yield return messageBuilder.EmitDone();
}
private static bool IsSameMessage(string? currentId, string? previousId) =>
currentId is not { Length: > 0 } || previousId is not { Length: > 0 } || currentId == previousId;
private static ResponseUsage ConvertUsage(UsageDetails details, ResponseUsage? existing)
{
var inputTokens = (long)(details.InputTokenCount ?? 0);
var outputTokens = (long)(details.OutputTokenCount ?? 0);
var totalTokens = (long)(details.TotalTokenCount ?? 0);
if (existing is not null)
{
inputTokens += existing.InputTokens;
outputTokens += existing.OutputTokens;
totalTokens += existing.TotalTokens;
}
return AzureAIAgentServerResponsesModelFactory.ResponseUsage(
inputTokens: inputTokens,
outputTokens: outputTokens,
totalTokens: totalTokens);
}
private static IEnumerable<ResponseStreamEvent> EmitWorkflowEvent(
ResponseEventStream stream,
WorkflowEvent workflowEvent,
Dictionary<string, string> executorItemIds)
{
switch (workflowEvent)
{
case ExecutorInvokedEvent invokedEvent:
{
var itemId = GenerateItemId("wfa");
executorItemIds[invokedEvent.ExecutorId] = itemId;
var item = new WorkflowActionOutputItem(
kind: "InvokeExecutor",
actionId: invokedEvent.ExecutorId,
status: WorkflowActionOutputItemStatus.InProgress,
id: itemId);
var builder = stream.AddOutputItem<WorkflowActionOutputItem>(itemId);
yield return builder.EmitAdded(item);
yield return builder.EmitDone(item);
break;
}
case ExecutorCompletedEvent completedEvent:
{
var itemId = GenerateItemId("wfa");
var item = new WorkflowActionOutputItem(
kind: "InvokeExecutor",
actionId: completedEvent.ExecutorId,
status: WorkflowActionOutputItemStatus.Completed,
id: itemId);
var builder = stream.AddOutputItem<WorkflowActionOutputItem>(itemId);
yield return builder.EmitAdded(item);
yield return builder.EmitDone(item);
executorItemIds.Remove(completedEvent.ExecutorId);
break;
}
case ExecutorFailedEvent failedEvent:
{
var itemId = GenerateItemId("wfa");
var item = new WorkflowActionOutputItem(
kind: "InvokeExecutor",
actionId: failedEvent.ExecutorId,
status: WorkflowActionOutputItemStatus.Failed,
id: itemId);
var builder = stream.AddOutputItem<WorkflowActionOutputItem>(itemId);
yield return builder.EmitAdded(item);
yield return builder.EmitDone(item);
executorItemIds.Remove(failedEvent.ExecutorId);
break;
}
// Informational/lifecycle events — no SDK output needed.
// Note: AgentResponseUpdateEvent and WorkflowErrorEvent are unwrapped by
// WorkflowSession.InvokeStageAsync() into regular AgentResponseUpdate objects
// with populated Contents (TextContent, ErrorContent, etc.), so they flow
// through the normal content processing path above — not through this method.
case SuperStepStartedEvent:
case SuperStepCompletedEvent:
case WorkflowStartedEvent:
case WorkflowWarningEvent:
case RequestInfoEvent:
break;
}
}
/// <summary>
/// Generates a valid item ID matching the SDK's <c>{prefix}_{50chars}</c> format.
/// </summary>
private static string GenerateItemId(string prefix)
{
// SDK format: {prefix}_{50 char body}
var bytes = RandomNumberGenerator.GetBytes(25);
var body = Convert.ToHexString(bytes); // 50 hex chars, uppercase
return $"{prefix}_{body}";
}
}
@@ -0,0 +1,78 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.AI.AgentServer.Responses;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Microsoft.Agents.AI.Hosting.AzureAIResponses;
/// <summary>
/// Extension methods for <see cref="IServiceCollection"/> to register the agent-framework
/// response handler with the Azure AI Responses Server SDK.
/// </summary>
public static class AgentFrameworkResponsesServiceCollectionExtensions
{
/// <summary>
/// Registers <see cref="AgentFrameworkResponseHandler"/> as the <see cref="ResponseHandler"/>
/// for the Azure AI Responses Server SDK. Agents are resolved from keyed DI services
/// using the <c>agent.name</c> or <c>metadata["entity_id"]</c> from incoming requests.
/// </summary>
/// <remarks>
/// <para>
/// Call this method <b>after</b> <c>AddResponsesServer()</c> and after registering your
/// <see cref="AIAgent"/> instances (e.g., via <c>AddAIAgent()</c>).
/// </para>
/// <para>
/// Example:
/// <code>
/// builder.Services.AddResponsesServer();
/// builder.AddAIAgent("my-agent", ...);
/// builder.Services.AddAgentFrameworkHandler();
///
/// var app = builder.Build();
/// app.MapResponsesServer();
/// </code>
/// </para>
/// </remarks>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddAgentFrameworkHandler(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
return services;
}
/// <summary>
/// Registers a specific <see cref="AIAgent"/> as the handler for all incoming requests,
/// regardless of the <c>agent.name</c> in the request.
/// </summary>
/// <remarks>
/// <para>
/// Use this overload when hosting a single agent. The provided agent instance is
/// registered both as a keyed service and as the default <see cref="AIAgent"/>.
/// </para>
/// <para>
/// Example:
/// <code>
/// builder.Services.AddResponsesServer();
/// builder.Services.AddAgentFrameworkHandler(myAgent);
///
/// var app = builder.Build();
/// app.MapResponsesServer();
/// </code>
/// </para>
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="agent">The agent instance to register.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddAgentFrameworkHandler(this IServiceCollection services, AIAgent agent)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(agent);
services.TryAddSingleton(agent);
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
return services;
}
}