Merge branch 'main' into feature-harness

This commit is contained in:
westey
2026-04-22 10:32:48 +01:00
committed by GitHub
492 changed files with 35523 additions and 4202 deletions
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Aspire.Hosting.AgentFramework;
/// <summary>
/// Describes an AI agent exposed by an agent service backend, used for entity discovery in DevUI.
/// </summary>
/// <remarks>
/// <para>
/// When added via <see cref="AgentFrameworkBuilderExtensions.WithAgentService{TSource}"/>,
/// agent metadata is declared at the AppHost level so that the DevUI aggregator can build the
/// entity listing without querying each backend's <c>/v1/entities</c> endpoint.
/// </para>
/// <para>
/// Agent services only need to expose the standard OpenAI Responses and Conversations API endpoints
/// (<c>MapOpenAIResponses</c> and <c>MapOpenAIConversations</c>), not a custom discovery endpoint.
/// </para>
/// </remarks>
/// <param name="Id">The unique identifier for the agent, typically matching the name passed to <c>AddAIAgent</c>.</param>
/// <param name="Description">A short description of the agent's capabilities.</param>
public record AgentEntityInfo(string Id, string? Description = null)
{
/// <summary>
/// Gets the display name for the agent. Defaults to <see cref="Id"/> if not specified.
/// </summary>
public string Name { get; init; } = Id;
/// <summary>
/// Gets the entity type. Defaults to <c>"agent"</c>.
/// </summary>
public string Type { get; init; } = "agent";
/// <summary>
/// Gets the framework identifier. Defaults to <c>"agent_framework"</c>.
/// </summary>
public string Framework { get; init; } = "agent_framework";
}
@@ -0,0 +1,185 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Aspire.Hosting.AgentFramework;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting;
/// <summary>
/// Provides extension methods for adding Agent Framework DevUI resources to the application model.
/// </summary>
public static class AgentFrameworkBuilderExtensions
{
/// <summary>
/// Adds a DevUI resource for testing AI agents in a distributed application.
/// </summary>
/// <remarks>
/// <para>
/// DevUI is a web-based interface for testing and debugging AI agents using the OpenAI Responses protocol.
/// When configured with <see cref="WithAgentService{TSource}"/>, it aggregates agents from multiple backend services
/// and provides a unified testing interface.
/// </para>
/// <para>
/// The aggregator runs as an in-process reverse proxy within the AppHost, requiring no external container image.
/// It serves the DevUI frontend from embedded resources in Microsoft.Agents.AI.DevUI when available, and
/// falls back to proxying from the first configured backend. It aggregates entity listings from all backends.
/// </para>
/// <para>
/// This resource is excluded from the deployment manifest as it is intended for development use only.
/// </para>
/// </remarks>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <param name="name">The name to give the resource.</param>
/// <param name="port">The host port for the DevUI web interface. If not specified, a random port will be assigned.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <example>
/// <code>
/// var devui = builder.AddDevUI("devui")
/// .WithAgentService(dotnetAgent)
/// .WithAgentService(pythonAgent);
/// </code>
/// </example>
public static IResourceBuilder<DevUIResource> AddDevUI(
this IDistributedApplicationBuilder builder,
string name,
int? port = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);
var resource = new DevUIResource(name, port);
var resourceBuilder = builder.AddResource(resource)
.ExcludeFromManifest(); // DevUI is a dev-only tool
// Initialize the in-process aggregator when the resource is initialized by the orchestrator
builder.Eventing.Subscribe<InitializeResourceEvent>(resource, async (e, ct) =>
{
var logger = e.Logger;
var aggregator = new DevUIAggregatorHostedService(resource, e.Services.GetRequiredService<ILoggerFactory>().CreateLogger<DevUIAggregatorHostedService>());
try
{
// Wait for dependencies (e.g. agent service backends) before starting.
// Custom resources must manually publish BeforeResourceStartedEvent to trigger
// the orchestrator's WaitFor mechanism.
await e.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, e.Services), ct).ConfigureAwait(false);
await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = KnownResourceStates.Starting
}).ConfigureAwait(false);
await aggregator.StartAsync(ct).ConfigureAwait(false);
// Allocate the endpoint so the URL appears in the Aspire dashboard
var endpointAnnotation = resource.Annotations
.OfType<EndpointAnnotation>()
.First(ea => ea.Name == DevUIResource.PrimaryEndpointName);
endpointAnnotation.AllocatedEndpoint = new AllocatedEndpoint(
endpointAnnotation, "localhost", aggregator.AllocatedPort);
var devuiUrl = $"http://localhost:{aggregator.AllocatedPort}/devui/";
await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = KnownResourceStates.Running,
Urls = [new UrlSnapshot("DevUI", devuiUrl, IsInternal: false)]
}).ConfigureAwait(false);
// Shut down the aggregator when the app stops
var lifetime = e.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() =>
{
e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = KnownResourceStates.Finished
}).GetAwaiter().GetResult();
aggregator.StopAsync(CancellationToken.None).GetAwaiter().GetResult();
aggregator.DisposeAsync().AsTask().GetAwaiter().GetResult();
});
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to start DevUI aggregator");
await aggregator.DisposeAsync().ConfigureAwait(false);
await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = KnownResourceStates.FailedToStart
}).ConfigureAwait(false);
}
});
return resourceBuilder;
}
/// <summary>
/// Configures DevUI to connect to an agent service backend.
/// </summary>
/// <remarks>
/// <para>
/// Each agent service should expose the OpenAI Responses and Conversations API endpoints
/// (via <c>MapOpenAIResponses</c> and <c>MapOpenAIConversations</c>).
/// </para>
/// <para>
/// When <paramref name="agents"/> is provided, the aggregator builds the entity listing from
/// these declarations without querying the backend. When not provided, a single agent named
/// after the service resource is assumed. Agent services don't need a <c>/v1/entities</c> endpoint.
/// </para>
/// </remarks>
/// <typeparam name="TSource">The type of the agent service resource.</typeparam>
/// <param name="builder">The DevUI resource builder.</param>
/// <param name="agentService">The agent service resource to connect to.</param>
/// <param name="agents">
/// Optional list of agents declared by this backend. When provided, the aggregator uses these
/// declarations directly. When not provided, defaults to a single agent named after the
/// <paramref name="agentService"/> resource. The backend doesn't need to expose a
/// <c>/v1/entities</c> endpoint in either case.
/// </param>
/// <param name="entityIdPrefix">
/// An optional prefix to add to entity IDs from this backend.
/// If not specified, the resource name will be used as the prefix.
/// </param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <example>
/// <code>
/// var writerAgent = builder.AddProject&lt;Projects.WriterAgent&gt;("writer-agent");
/// var editorAgent = builder.AddProject&lt;Projects.EditorAgent&gt;("editor-agent");
///
/// builder.AddDevUI("devui")
/// .WithAgentService(writerAgent, agents: [new("writer", "Writes short stories")])
/// .WithAgentService(editorAgent, agents: [new("editor", "Edits and formats stories")])
/// .WaitFor(writerAgent)
/// .WaitFor(editorAgent);
/// </code>
/// </example>
public static IResourceBuilder<DevUIResource> WithAgentService<TSource>(
this IResourceBuilder<DevUIResource> builder,
IResourceBuilder<TSource> agentService,
IReadOnlyList<AgentEntityInfo>? agents = null,
string? entityIdPrefix = null)
where TSource : IResourceWithEndpoints
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(agentService);
// Default to a single agent named after the service resource
agents ??= [new AgentEntityInfo(agentService.Resource.Name)];
builder.WithAnnotation(new AgentServiceAnnotation(agentService.Resource, entityIdPrefix, agents));
builder.WithRelationship(agentService.Resource, "agent-backend");
return builder;
}
}
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Aspire.Hosting.AgentFramework;
namespace Aspire.Hosting.ApplicationModel;
/// <summary>
/// An annotation that tracks an agent service backend referenced by a DevUI resource.
/// </summary>
/// <remarks>
/// This annotation is used to configure DevUI to aggregate entities from multiple
/// agent service backends. Each annotation represents one backend that DevUI should
/// connect to for entity discovery and request routing.
/// </remarks>
public class AgentServiceAnnotation : IResourceAnnotation
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentServiceAnnotation"/> class.
/// </summary>
/// <param name="agentService">The agent service resource.</param>
/// <param name="entityIdPrefix">
/// An optional prefix to add to entity IDs from this backend to avoid conflicts.
/// If not specified, the resource name will be used as the prefix.
/// </param>
/// <param name="agents">
/// Optional list of agents declared by this backend. When provided, the aggregator builds the entity
/// listing directly from these declarations instead of querying the backend's <c>/v1/entities</c> endpoint.
/// </param>
public AgentServiceAnnotation(IResource agentService, string? entityIdPrefix = null, IReadOnlyList<AgentEntityInfo>? agents = null)
{
ArgumentNullException.ThrowIfNull(agentService);
this.AgentService = agentService;
this.EntityIdPrefix = entityIdPrefix;
this.Agents = agents ?? [];
}
/// <summary>
/// Gets the agent service resource that exposes AI agents.
/// </summary>
public IResource AgentService { get; }
/// <summary>
/// Gets the prefix to use for entity IDs from this backend.
/// </summary>
/// <remarks>
/// When <c>null</c>, the resource name will be used as the prefix.
/// Entity IDs will be formatted as "{prefix}/{entityId}" to ensure uniqueness
/// across multiple agent backends.
/// </remarks>
public string? EntityIdPrefix { get; }
/// <summary>
/// Gets the list of agents declared by this backend.
/// </summary>
/// <remarks>
/// When non-empty, the DevUI aggregator uses these declarations to build the entity listing
/// without querying the backend. When empty, the aggregator falls back to calling
/// <c>GET /v1/entities</c> on the backend for discovery.
/// </remarks>
public IReadOnlyList<AgentEntityInfo> Agents { get; }
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<IsPackable>true</IsPackable>
<PackageTags>aspire integration hosting agent-framework devui ai agents</PackageTags>
<Description>Microsoft Agent Framework DevUI support for Aspire.</Description>
<!-- Suppress analyzer warnings for Aspire integration code -->
<!-- IL2026/IL3050: Suppress trimming/AOT warnings - DevUI is a dev-only tool not intended for AOT -->
<NoWarn>$(NoWarn);CA1873;RCS1061;VSTHRD002;IL2026;IL3050</NoWarn>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Aspire.Hosting.AgentFramework.DevUI.UnitTests" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting" />
</ItemGroup>
</Project>
@@ -0,0 +1,779 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Aspire.Hosting.ApplicationModel;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting.AgentFramework;
/// <summary>
/// Hosts an in-process reverse proxy that aggregates DevUI entities from multiple agent backends.
/// Serves the DevUI frontend directly from the <c>Microsoft.Agents.AI.DevUI</c> assembly's embedded
/// resources and intercepts API calls to provide multi-backend entity aggregation and request routing.
/// </summary>
internal sealed class DevUIAggregatorHostedService : IAsyncDisposable
{
private static readonly FileExtensionContentTypeProvider s_contentTypeProvider = new();
private WebApplication? _app;
private readonly DevUIResource _resource;
private readonly ILogger _logger;
// Frontend resources loaded from the Microsoft.Agents.AI.DevUI assembly (null if unavailable)
private readonly Dictionary<string, (string ResourceName, string ContentType)>? _frontendResources;
// Maps conversation IDs to backend URLs for routing GET requests that lack agent_id context.
// Populated when the aggregator routes conversation requests to a positively-resolved backend.
private readonly ConcurrentDictionary<string, string> _conversationBackendMap = new(StringComparer.OrdinalIgnoreCase);
public DevUIAggregatorHostedService(
DevUIResource resource,
ILogger logger)
{
this._resource = resource;
this._logger = logger;
this._frontendResources = LoadFrontendResources(logger);
}
/// <summary>
/// Gets the port the aggregator is listening on, available after <see cref="StartAsync"/>.
/// </summary>
internal int AllocatedPort { get; private set; }
public async Task StartAsync(CancellationToken cancellationToken)
{
var builder = WebApplication.CreateSlimBuilder();
builder.Logging.ClearProviders();
builder.Services.AddHttpClient("devui-proxy")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AllowAutoRedirect = false
});
this._app = builder.Build();
// Bind to a fixed port if one was specified on the DevUI resource; otherwise use 0 for dynamic allocation.
var port = this._resource.Port ?? 0;
this._app.Urls.Add($"http://127.0.0.1:{port}");
this.MapRoutes(this._app);
await this._app.StartAsync(cancellationToken).ConfigureAwait(false);
var serverAddresses = this._app.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>();
if (serverAddresses is not null)
{
var address = serverAddresses.Addresses.First();
var uri = new Uri(address);
this.AllocatedPort = uri.Port;
this._logger.LogInformation("DevUI aggregator started on port {Port}", this.AllocatedPort);
}
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (this._app is not null)
{
await this._app.StopAsync(cancellationToken).ConfigureAwait(false);
}
}
public async ValueTask DisposeAsync()
{
if (this._app is not null)
{
await this._app.DisposeAsync().ConfigureAwait(false);
this._app = null;
}
}
/// <summary>
/// Loads the DevUI frontend resources from the <c>Microsoft.Agents.AI.DevUI</c> assembly.
/// The assembly embeds the Vite SPA build output as manifest resources.
/// Returns null if the assembly is not available.
/// </summary>
private static Dictionary<string, (string ResourceName, string ContentType)>? LoadFrontendResources(ILogger logger)
{
Assembly assembly;
try
{
assembly = Assembly.Load("Microsoft.Agents.AI.DevUI");
}
catch (Exception ex)
{
logger.LogDebug(ex, "Microsoft.Agents.AI.DevUI assembly not found. Frontend will be proxied from backends.");
return null;
}
var prefix = $"{assembly.GetName().Name}.resources.";
var resources = new Dictionary<string, (string, string)>(StringComparer.OrdinalIgnoreCase);
foreach (var name in assembly.GetManifestResourceNames())
{
if (!name.StartsWith(prefix, StringComparison.Ordinal))
{
continue;
}
// The DevUI middleware maps resource names by replacing dots with slashes.
// Both the key and lookup use the same transform, so they match.
var key = name[prefix.Length..].Replace('.', '/');
s_contentTypeProvider.TryGetContentType(name, out var contentType);
resources[key] = (name, contentType ?? "application/octet-stream");
}
if (resources.Count == 0)
{
logger.LogWarning("Microsoft.Agents.AI.DevUI assembly loaded but contains no frontend resources");
return null;
}
logger.LogDebug("Loaded {Count} DevUI frontend resources from assembly", resources.Count);
return resources;
}
/// <summary>
/// Serves the DevUI frontend. Uses embedded assembly resources if available,
/// otherwise falls back to proxying from the first backend agent service.
/// </summary>
private async Task ServeDevUIFrontendAsync(HttpContext context, string? path)
{
// Redirect /devui to /devui/ so relative URLs in the SPA resolve correctly
if (string.IsNullOrEmpty(path) && context.Request.Path.Value is { } reqPath && !reqPath.EndsWith('/'))
{
var redirect = reqPath + "/";
if (context.Request.QueryString.HasValue)
{
redirect += context.Request.QueryString.Value;
}
context.Response.StatusCode = StatusCodes.Status301MovedPermanently;
context.Response.Headers.Location = redirect;
return;
}
// Try embedded resources first
if (this._frontendResources is not null)
{
var resourcePath = string.IsNullOrEmpty(path) ? "index.html" : path;
if (await this.TryServeResourceAsync(context, resourcePath).ConfigureAwait(false))
{
return;
}
// SPA fallback: serve index.html for paths without a file extension (client-side routing)
if (!resourcePath.Contains('.', StringComparison.Ordinal) &&
await this.TryServeResourceAsync(context, "index.html").ConfigureAwait(false))
{
return;
}
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
// Fallback: proxy from the first backend that serves /devui
var backends = this.ResolveBackends();
var firstBackendUrl = backends.Values.FirstOrDefault();
if (firstBackendUrl is null)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
context.Response.ContentType = "text/plain";
await context.Response.WriteAsync(
"DevUI: No agent service backends are available yet.", context.RequestAborted).ConfigureAwait(false);
return;
}
var targetPath = string.IsNullOrEmpty(path) ? "/devui/" : $"/devui/{path}";
await ProxyRequestAsync(
context, firstBackendUrl, targetPath + context.Request.QueryString, bodyBytes: null).ConfigureAwait(false);
}
private async Task<bool> TryServeResourceAsync(HttpContext context, string resourcePath)
{
if (this._frontendResources is null)
{
return false;
}
var key = resourcePath.Replace('.', '/');
if (!this._frontendResources.TryGetValue(key, out var entry))
{
return false;
}
Assembly assembly;
try
{
assembly = Assembly.Load("Microsoft.Agents.AI.DevUI");
}
catch
{
return false;
}
using var stream = assembly.GetManifestResourceStream(entry.ResourceName);
if (stream is null)
{
return false;
}
context.Response.ContentType = entry.ContentType;
context.Response.Headers.CacheControl = "no-cache, no-store";
await stream.CopyToAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false);
return true;
}
private static IResult GetMeta()
{
return Results.Json(new
{
ui_mode = "developer",
version = "0.1.0",
framework = "agent_framework",
runtime = "dotnet",
capabilities = new Dictionary<string, bool>
{
["tracing"] = false,
["openai_proxy"] = false,
["deployment"] = false
},
auth_required = false
});
}
private void MapRoutes(WebApplication app)
{
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
// Intercept API calls for multi-backend aggregation and routing
app.MapGet("/v1/entities", (Delegate)this.AggregateEntitiesAsync);
app.MapGet("/v1/entities/{**entityPath}", this.RouteEntityInfoAsync);
app.MapPost("/v1/responses", this.RouteResponsesAsync);
app.Map("/v1/conversations/{**path}", this.ProxyConversationsAsync);
app.MapGet("/meta", GetMeta);
// Serve the DevUI frontend from embedded assembly resources
app.Map("/devui/{**path}", this.ServeDevUIFrontendAsync);
}
/// <summary>
/// Resolves backend URLs from the resource's <see cref="AgentServiceAnnotation"/> annotations.
/// This method does not cache results to ensure late-allocated backends are always discovered.
/// </summary>
private Dictionary<string, string> ResolveBackends()
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var annotation in this._resource.Annotations.OfType<AgentServiceAnnotation>())
{
if (annotation.AgentService is not IResourceWithEndpoints rwe)
{
continue;
}
var prefix = annotation.EntityIdPrefix ?? annotation.AgentService.Name;
try
{
var endpoint = rwe.GetEndpoint("http");
if (endpoint.IsAllocated)
{
result[prefix] = endpoint.Url;
}
}
catch (Exception ex)
{
this._logger.LogDebug(ex, "Backend '{Prefix}' endpoint not yet available", prefix);
}
}
return result;
}
private async Task<IResult> AggregateEntitiesAsync(HttpContext context)
{
var backends = this.ResolveBackends();
var allEntities = new JsonArray();
foreach (var annotation in this._resource.Annotations.OfType<AgentServiceAnnotation>())
{
var prefix = annotation.EntityIdPrefix ?? annotation.AgentService.Name;
if (annotation.Agents.Count > 0)
{
// Build entities from AppHost-declared metadata — no backend call needed
foreach (var agent in annotation.Agents)
{
allEntities.Add(new JsonObject
{
["id"] = $"{prefix}/{agent.Id}",
["type"] = agent.Type,
["name"] = agent.Name,
["description"] = agent.Description,
["framework"] = agent.Framework,
["_original_id"] = agent.Id,
["_backend"] = prefix
});
}
continue;
}
// Fallback: query backend /v1/entities for discovery
if (!backends.TryGetValue(prefix, out var baseUrl))
{
continue;
}
try
{
var httpClientFactory = context.RequestServices.GetRequiredService<IHttpClientFactory>();
using var client = httpClientFactory.CreateClient("devui-proxy");
var response = await client.GetAsync(
new Uri(new Uri(baseUrl), "/v1/entities"),
context.RequestAborted).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
this._logger.LogWarning(
"Failed to fetch entities from backend '{Prefix}' at {Url}: {Status}",
prefix, baseUrl, response.StatusCode);
continue;
}
var json = await response.Content.ReadAsStringAsync(context.RequestAborted).ConfigureAwait(false);
var doc = JsonNode.Parse(json);
var entities = doc?["entities"]?.AsArray();
if (entities is null)
{
continue;
}
foreach (var entity in entities)
{
if (entity is null)
{
continue;
}
var cloned = entity.DeepClone();
var id = cloned["id"]?.GetValue<string>() ?? cloned["name"]?.GetValue<string>();
if (id is not null)
{
cloned["id"] = $"{prefix}/{id}";
cloned["_original_id"] = id;
cloned["_backend"] = prefix;
}
allEntities.Add(cloned);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
this._logger.LogWarning(ex, "Error fetching entities from backend '{Prefix}' at {Url}", prefix, baseUrl);
}
}
return Results.Json(new { entities = allEntities });
}
private async Task RouteEntityInfoAsync(HttpContext context, string entityPath)
{
var (backendUrl, actualPath) = this.ResolveBackend(entityPath);
if (backendUrl is null)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
var httpClientFactory = context.RequestServices.GetRequiredService<IHttpClientFactory>();
using var client = httpClientFactory.CreateClient("devui-proxy");
var targetUrl = new Uri(new Uri(backendUrl), $"/v1/entities/{actualPath}");
using var response = await client.GetAsync(targetUrl, context.RequestAborted).ConfigureAwait(false);
await CopyResponseAsync(response, context).ConfigureAwait(false);
}
private async Task RouteResponsesAsync(HttpContext context)
{
var bodyBytes = await ReadRequestBodyAsync(context.Request).ConfigureAwait(false);
var json = JsonNode.Parse(bodyBytes);
var entityId = json?["metadata"]?["entity_id"]?.GetValue<string>();
if (entityId is null)
{
var firstBackend = this.ResolveBackends().Values.FirstOrDefault();
if (firstBackend is null)
{
context.Response.StatusCode = StatusCodes.Status502BadGateway;
return;
}
await ProxyRequestAsync(context, firstBackend, "/v1/responses", bodyBytes).ConfigureAwait(false);
return;
}
var (backendUrl, actualEntityId) = this.ResolveBackend(entityId);
if (backendUrl is null)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsJsonAsync(
new { error = $"No backend found for entity '{entityId}'" },
context.RequestAborted).ConfigureAwait(false);
return;
}
// Rewrite entity_id to the un-prefixed original value
json!["metadata"]!["entity_id"] = actualEntityId;
var rewrittenBody = JsonSerializer.SerializeToUtf8Bytes(json);
await ProxyRequestAsync(context, backendUrl, "/v1/responses", rewrittenBody, streaming: true).ConfigureAwait(false);
}
private async Task ProxyConversationsAsync(HttpContext context, string? path)
{
// Try to determine the backend from agent_id query param or request body
string? backendUrl = null;
string? actualAgentId = null;
var agentId = context.Request.Query["agent_id"].FirstOrDefault();
if (agentId is not null)
{
(backendUrl, actualAgentId) = this.ResolveBackend(agentId);
}
// Build query string with rewritten agent_id if we resolved from query param
var queryString = (agentId is not null && actualAgentId is not null)
? RewriteAgentIdInQueryString(context.Request.QueryString, actualAgentId)
: context.Request.QueryString.ToString();
// Try conversation→backend map for previously-seen conversations
if (backendUrl is null)
{
var conversationId = ExtractConversationId(path);
if (conversationId is not null && this._conversationBackendMap.TryGetValue(conversationId, out var mappedUrl))
{
backendUrl = mappedUrl;
}
}
// Always read the request body when present so it isn't dropped during proxying
byte[]? bodyBytes = null;
if (context.Request.ContentLength > 0)
{
bodyBytes = await ReadRequestBodyAsync(context.Request).ConfigureAwait(false);
}
// Try to resolve backend from request body metadata when not yet determined
if (backendUrl is null && bodyBytes is not null)
{
var json = JsonNode.Parse(bodyBytes);
var entityId = json?["metadata"]?["entity_id"]?.GetValue<string>()
?? json?["metadata"]?["agent_id"]?.GetValue<string>();
if (entityId is not null)
{
string actualId;
(backendUrl, actualId) = this.ResolveBackend(entityId);
if (backendUrl is not null)
{
// Rewrite the entity/agent id to the un-prefixed value
if (json?["metadata"]?["entity_id"] is not null)
{
json!["metadata"]!["entity_id"] = actualId;
}
if (json?["metadata"]?["agent_id"] is not null)
{
json!["metadata"]!["agent_id"] = actualId;
}
bodyBytes = JsonSerializer.SerializeToUtf8Bytes(json);
var targetPath = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}";
// Also rewrite query string agent_id if present
var bodyQueryString = (agentId is not null)
? RewriteAgentIdInQueryString(context.Request.QueryString, actualId)
: context.Request.QueryString.ToString();
await this.ProxyAndRecordConversationAsync(
context, backendUrl, path, targetPath + bodyQueryString, bodyBytes).ConfigureAwait(false);
return;
}
}
// Couldn't determine backend from body; proxy raw bytes to first backend
backendUrl = this.ResolveBackends().Values.FirstOrDefault();
if (backendUrl is null)
{
context.Response.StatusCode = StatusCodes.Status502BadGateway;
return;
}
var targetPathFallback = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}";
await ProxyRequestAsync(
context, backendUrl, targetPathFallback + queryString, bodyBytes).ConfigureAwait(false);
return;
}
// Route to resolved backend (from query or conversation map), or fall back to first backend
var backendKnown = backendUrl is not null;
backendUrl ??= this.ResolveBackends().Values.FirstOrDefault();
if (backendUrl is null)
{
context.Response.StatusCode = StatusCodes.Status502BadGateway;
return;
}
var convPath = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}";
if (backendKnown)
{
await this.ProxyAndRecordConversationAsync(
context, backendUrl, path, convPath + queryString, bodyBytes).ConfigureAwait(false);
}
else
{
await ProxyRequestAsync(
context, backendUrl, convPath + queryString, bodyBytes).ConfigureAwait(false);
}
}
/// <summary>
/// Rewrites the agent_id query parameter to the un-prefixed value for backend routing.
/// </summary>
internal static string RewriteAgentIdInQueryString(QueryString queryString, string actualAgentId)
{
if (!queryString.HasValue)
{
return string.Empty;
}
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString.Value);
query["agent_id"] = actualAgentId;
return QueryString.Create(query).ToString();
}
private static string? ExtractConversationId(string? path)
{
if (string.IsNullOrEmpty(path))
{
return null;
}
var slashIndex = path.IndexOf('/');
return slashIndex > 0 ? path[..slashIndex] : path;
}
/// <summary>
/// Records the conversation→backend mapping and proxies the request.
/// For creation POSTs (no conversation ID in path), intercepts the response to capture the new ID.
/// </summary>
private async Task ProxyAndRecordConversationAsync(
HttpContext context,
string backendUrl,
string? conversationPath,
string targetUrl,
byte[]? bodyBytes)
{
var conversationId = ExtractConversationId(conversationPath);
if (conversationId is not null)
{
// We already know the conversation ID — record and proxy normally
this._conversationBackendMap[conversationId] = backendUrl;
await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false);
return;
}
// Creation POST: intercept response to capture the new conversation ID
if (!context.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase))
{
await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false);
return;
}
var originalBody = context.Response.Body;
using var buffer = new MemoryStream();
context.Response.Body = buffer;
try
{
await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false);
if (context.Response.StatusCode is >= 200 and < 300)
{
buffer.Position = 0;
try
{
using var doc = await JsonDocument.ParseAsync(
buffer, cancellationToken: context.RequestAborted).ConfigureAwait(false);
if (doc.RootElement.TryGetProperty("id", out var idProp) &&
idProp.ValueKind == JsonValueKind.String)
{
var createdId = idProp.GetString();
if (createdId is not null)
{
this._conversationBackendMap[createdId] = backendUrl;
this._logger.LogDebug(
"Recorded conversation '{ConversationId}' → backend '{BackendUrl}'",
createdId, backendUrl);
}
}
}
catch
{
// Best-effort: response may not be parseable JSON
}
}
}
finally
{
context.Response.Body = originalBody;
buffer.Position = 0;
await buffer.CopyToAsync(originalBody, context.RequestAborted).ConfigureAwait(false);
}
}
private static async Task ProxyRequestAsync(
HttpContext context,
string backendUrl,
string path,
byte[]? bodyBytes,
bool streaming = false)
{
var httpClientFactory = context.RequestServices.GetRequiredService<IHttpClientFactory>();
using var client = httpClientFactory.CreateClient("devui-proxy");
var targetUri = new Uri(new Uri(backendUrl), path);
using var request = new HttpRequestMessage(new HttpMethod(context.Request.Method), targetUri);
foreach (var header in context.Request.Headers)
{
if (IsHopByHopHeader(header.Key))
{
continue;
}
request.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
}
if (bodyBytes is not null)
{
request.Content = new ByteArrayContent(bodyBytes);
if (context.Request.ContentType is not null)
{
request.Content.Headers.ContentType =
System.Net.Http.Headers.MediaTypeHeaderValue.Parse(context.Request.ContentType);
}
}
var completionOption = streaming
? HttpCompletionOption.ResponseHeadersRead
: HttpCompletionOption.ResponseContentRead;
using var response = await client.SendAsync(
request, completionOption, context.RequestAborted).ConfigureAwait(false);
if (streaming && response.Content.Headers.ContentType?.MediaType == "text/event-stream")
{
context.Response.StatusCode = (int)response.StatusCode;
context.Response.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
using var stream = await response.Content.ReadAsStreamAsync(context.RequestAborted).ConfigureAwait(false);
await stream.CopyToAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false);
}
else
{
await CopyResponseAsync(response, context).ConfigureAwait(false);
}
}
private (string? BackendUrl, string ActualPath) ResolveBackend(string prefixedId)
{
var backends = this.ResolveBackends();
var slashIndex = prefixedId.IndexOf('/');
if (slashIndex > 0)
{
var prefix = prefixedId[..slashIndex];
var rest = prefixedId[(slashIndex + 1)..];
if (backends.TryGetValue(prefix, out var url))
{
return (url, rest);
}
}
// Fallback: check all prefixes
foreach (var (prefix, url) in backends)
{
if (prefixedId.StartsWith(prefix + "/", StringComparison.Ordinal))
{
return (url, prefixedId[(prefix.Length + 1)..]);
}
}
return (null, prefixedId);
}
private static async Task<byte[]> ReadRequestBodyAsync(HttpRequest request)
{
using var ms = new MemoryStream();
await request.Body.CopyToAsync(ms).ConfigureAwait(false);
return ms.ToArray();
}
private static async Task CopyResponseAsync(HttpResponseMessage response, HttpContext context)
{
context.Response.StatusCode = (int)response.StatusCode;
foreach (var header in response.Headers.Where(h => !IsHopByHopHeader(h.Key)))
{
context.Response.Headers[header.Key] = header.Value.ToArray();
}
foreach (var header in response.Content.Headers)
{
context.Response.Headers[header.Key] = header.Value.ToArray();
}
await response.Content.CopyToAsync(context.Response.Body).ConfigureAwait(false);
}
private static bool IsHopByHopHeader(string headerName)
{
return headerName.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase)
|| headerName.Equals("Connection", StringComparison.OrdinalIgnoreCase)
|| headerName.Equals("Keep-Alive", StringComparison.OrdinalIgnoreCase)
|| headerName.Equals("Host", StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net.Sockets;
namespace Aspire.Hosting.ApplicationModel;
/// <summary>
/// Represents a DevUI resource for testing AI agents in a distributed application.
/// </summary>
/// <remarks>
/// DevUI aggregates agents from multiple backend services and provides a unified
/// web interface for testing and debugging AI agents using the OpenAI Responses protocol.
/// The aggregator runs as an in-process reverse proxy within the AppHost, requiring no
/// external container image.
/// </remarks>
/// <param name="name">The name of the DevUI resource.</param>
public class DevUIResource(string name) : Resource(name), IResourceWithEndpoints, IResourceWithWaitSupport
{
internal const string PrimaryEndpointName = "http";
/// <summary>
/// Initializes a new instance of the <see cref="DevUIResource"/> class with endpoint annotations.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="port">An optional fixed port. If <c>null</c>, a dynamic port is assigned.</param>
internal DevUIResource(string name, int? port) : this(name)
{
this.Port = port;
this.Annotations.Add(new EndpointAnnotation(
ProtocolType.Tcp,
uriScheme: "http",
name: PrimaryEndpointName,
port: port,
isProxied: false)
{
TargetHost = "localhost"
});
}
/// <summary>
/// Gets the optional fixed port for the DevUI web interface.
/// </summary>
internal int? Port { get; }
/// <summary>
/// Gets the primary HTTP endpoint for the DevUI web interface.
/// </summary>
public EndpointReference PrimaryEndpoint => field ??= new(this, PrimaryEndpointName);
}
@@ -0,0 +1,104 @@
# Aspire.Hosting.AgentFramework.DevUI library
Provides extension methods and resource definitions for an Aspire AppHost to configure a DevUI resource for testing and debugging AI agents built with [Microsoft Agent Framework](https://github.com/microsoft/agent-framework).
## Getting started
### Prerequisites
Agent services must expose the OpenAI Responses and Conversations API endpoints. This is compatible with services using [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) with `MapOpenAIResponses()` and `MapOpenAIConversations()` mapped.
### Install the package
In your AppHost project, install the Aspire Agent Framework DevUI Hosting library with [NuGet](https://www.nuget.org):
```dotnetcli
dotnet add package Aspire.Hosting.AgentFramework.DevUI
```
## Usage example
Then, in the _AppHost.cs_ file of `AppHost`, add a DevUI resource and connect it to your agent services using the following methods:
```csharp
var writerAgent = builder.AddProject<Projects.WriterAgent>("writer-agent")
.WithHttpHealthCheck("/health");
var editorAgent = builder.AddProject<Projects.EditorAgent>("editor-agent")
.WithHttpHealthCheck("/health");
var devui = builder.AddDevUI("devui")
.WithAgentService(writerAgent)
.WithAgentService(editorAgent)
.WaitFor(writerAgent)
.WaitFor(editorAgent);
```
Each agent service only needs to map the standard OpenAI API endpoints — no custom discovery endpoints are required:
```csharp
// In the agent service's Program.cs
builder.AddAIAgent("writer", "You write short stories.");
builder.Services.AddOpenAIResponses();
builder.Services.AddOpenAIConversations();
var app = builder.Build();
app.MapOpenAIResponses();
app.MapOpenAIConversations();
```
## How it works
`AddDevUI` starts an **in-process aggregator** inside the AppHost — no external container image is needed. The aggregator is a lightweight Kestrel server that:
1. **Serves the DevUI frontend** from the `Microsoft.Agents.AI.DevUI` assembly's embedded resources (loaded at runtime). If the assembly is not available, it falls back to proxying the frontend from the first backend.
2. **Aggregates entities** from all configured agent service backends into a single `/v1/entities` listing. Each entity ID is prefixed with the backend name to ensure uniqueness across services (e.g., `writer-agent/writer`, `editor-agent/editor`).
3. **Routes requests** to the correct backend based on the entity ID prefix. When DevUI sends a `POST /v1/responses` or `/v1/conversations` request, the aggregator strips the prefix and forwards it to the appropriate service.
4. **Streams SSE responses** for the `/v1/responses` endpoint, so agent responses stream back to the DevUI frontend in real time.
The aggregator publishes its URL to the Aspire dashboard, where it appears as a clickable link.
## Agent discovery
By default, `WithAgentService` declares a single agent named after the Aspire resource. You can provide explicit agent metadata when the agent name differs from the resource name, or when a service hosts multiple agents:
```csharp
builder.AddDevUI("devui")
.WithAgentService(writerAgent, agents: [new("writer", "Writes short stories")])
.WithAgentService(editorAgent, agents: [new("editor", "Edits and formats stories")]);
```
Agent metadata is declared at the AppHost level so the aggregator builds the entity listing directly — agent services don't need a `/v1/entities` endpoint.
## Configuration
### Custom entity ID prefix
By default, entity IDs are prefixed with the Aspire resource name. You can specify a custom prefix:
```csharp
builder.AddDevUI("devui")
.WithAgentService(myService, entityIdPrefix: "custom-prefix");
```
### Custom port
You can specify a fixed host port for the DevUI web interface:
```csharp
builder.AddDevUI("devui", port: 8090);
```
### DevUI frontend assembly
To serve the DevUI frontend directly from the aggregator (instead of proxying from a backend), add the `Microsoft.Agents.AI.DevUI` NuGet package to your AppHost project. The aggregator loads its embedded resources at runtime via `Assembly.Load`.
## Additional documentation
* https://github.com/microsoft/agent-framework
* https://github.com/microsoft/agent-framework/tree/main/dotnet/src/Microsoft.Agents.AI.DevUI
## Feedback & contributing
https://github.com/dotnet/aspire
@@ -0,0 +1,379 @@
// 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>
/// 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);
// 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 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: cancellationToken).ConfigureAwait(false);
if (inputItems.Count > 0)
{
messages.AddRange(InputConverter.ConvertItemsToMessages(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;
// 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,
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)
{
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)
{
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 AddAIAgent(\"{agentName}\", ...) or as a default AIAgent.";
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."
: $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AgentSessionStore.";
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,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Defines the contract for storing and retrieving agent conversation sessions.
/// </summary>
/// <remarks>
/// Implementations of this interface enable persistent storage of conversation sessions,
/// allowing conversations to be resumed across HTTP requests, application restarts,
/// or different service instances in hosted scenarios.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public abstract class AgentSessionStore
{
/// <summary>
/// Saves a serialized agent session to persistent storage.
/// </summary>
/// <param name="agent">The agent that owns this session.</param>
/// <param name="conversationId">The unique identifier for the conversation/session.</param>
/// <param name="session">The session to save.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task that represents the asynchronous save operation.</returns>
public abstract ValueTask SaveSessionAsync(
AIAgent agent,
string conversationId,
AgentSession session,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a serialized agent session from persistent storage.
/// </summary>
/// <param name="agent">The agent that owns this session.</param>
/// <param name="conversationId">The unique identifier for the conversation/session to retrieve.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A task that represents the asynchronous retrieval operation.
/// The task result contains the session, or a new session if not found.
/// </returns>
public abstract ValueTask<AgentSession> GetSessionAsync(
AIAgent agent,
string conversationId,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using ModelContextProtocol;
using ModelContextProtocol.Client;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// An <see cref="AIFunction"/> wrapper around <see cref="McpClientTool"/> that intercepts
/// JSON-RPC error -32006 (OAuth consent required) from the Foundry Toolsets proxy and
/// propagates it back to <see cref="AgentFrameworkResponseHandler"/> via
/// <see cref="McpConsentContext"/>.
/// </summary>
/// <remarks>
/// <para>
/// When the proxy returns -32006, the consent URL is stored in <see cref="McpConsentContext.Current"/>
/// and the per-request <see cref="RequestConsentState.CancellationSource"/> is cancelled. This causes
/// <see cref="FunctionInvokingChatClient"/> to stop the tool loop (it guards
/// exceptions with <c>when (!ct.IsCancellationRequested)</c>) and surfaces an
/// <see cref="System.OperationCanceledException"/> to the handler. The handler then emits the
/// <c>mcp_approval_request</c> output item and marks the response as <c>incomplete</c>.
/// </para>
/// </remarks>
internal sealed class ConsentAwareMcpClientAIFunction : AIFunction
{
private readonly McpClientTool _inner;
private readonly string _toolboxName;
internal ConsentAwareMcpClientAIFunction(McpClientTool inner, string toolboxName)
{
this._inner = inner;
this._toolboxName = toolboxName;
}
public override string Name => this._inner.Name;
public override string Description => this._inner.Description;
public override JsonElement JsonSchema => this._inner.JsonSchema;
public override JsonElement? ReturnJsonSchema => this._inner.ReturnJsonSchema;
public override JsonSerializerOptions JsonSerializerOptions => this._inner.JsonSerializerOptions;
protected override async ValueTask<object?> InvokeCoreAsync(
AIFunctionArguments arguments,
CancellationToken cancellationToken)
{
try
{
return await this._inner.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
}
catch (McpProtocolException ex) when ((int)ex.ErrorCode == -32006)
{
var state = McpConsentContext.Current.Value;
if (state is not null)
{
state.Pending = new McpConsentInfo(this._toolboxName, this._inner.Name, ex.Message);
state.CancellationSource?.Cancel();
}
cancellationToken.ThrowIfCancellationRequested();
throw; // fallback if the CT wasn't cancelled for some reason
}
}
}
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Azure.AI.Projects.Agents;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Extension methods for <see cref="FoundryAITool"/> that require Azure.AI.Projects 2.1.0-beta.1+
/// types (e.g. <see cref="ToolboxRecord"/>, <see cref="ToolboxVersion"/>).
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class FoundryAIToolExtensions
{
/// <summary>
/// Creates an <see cref="AITool"/> marker from a <see cref="ToolboxRecord"/> retrieved
/// from <c>AIProjectClient</c>. Uses <see cref="ToolboxRecord.Name"/> and
/// <see cref="ToolboxRecord.DefaultVersion"/>.
/// </summary>
/// <param name="toolbox">The toolbox record.</param>
/// <returns>An <see cref="AITool"/> marker backed by <see cref="HostedMcpToolboxAITool"/>.</returns>
public static AITool CreateHostedMcpToolbox(ToolboxRecord toolbox)
{
if (toolbox is null)
{
throw new ArgumentNullException(nameof(toolbox));
}
return new HostedMcpToolboxAITool(toolbox.Name, toolbox.DefaultVersion);
}
/// <summary>
/// Creates an <see cref="AITool"/> marker from a specific <see cref="ToolboxVersion"/>
/// retrieved from <c>AIProjectClient</c>. Uses <see cref="ToolboxVersion.Name"/> and
/// <see cref="ToolboxVersion.Version"/>.
/// </summary>
/// <param name="toolboxVersion">The toolbox version.</param>
/// <returns>An <see cref="AITool"/> marker backed by <see cref="HostedMcpToolboxAITool"/>.</returns>
public static AITool CreateHostedMcpToolbox(ToolboxVersion toolboxVersion)
{
if (toolboxVersion is null)
{
throw new ArgumentNullException(nameof(toolboxVersion));
}
return new HostedMcpToolboxAITool(toolboxVersion.Name, toolboxVersion.Version);
}
}
@@ -0,0 +1,109 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// An <see cref="DelegatingHandler"/> that:
/// <list type="bullet">
/// <item>Acquires a fresh Azure bearer token (scope: <c>https://cognitiveservices.azure.com/.default</c>) per request.</item>
/// <item>Injects the <c>Foundry-Features</c> header from <c>FOUNDRY_AGENT_TOOLSET_FEATURES</c> when non-empty.</item>
/// <item>Retries on HTTP 429, 500, 502, and 503 with exponential back-off (max 3 attempts, per spec §7).</item>
/// </list>
/// </summary>
internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
{
private const int MaxRetries = 3;
private static readonly TokenRequestContext s_tokenContext =
new(["https://cognitiveservices.azure.com/.default"]);
private readonly TokenCredential _credential;
private readonly string? _featuresHeaderValue;
internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? featuresHeaderValue)
{
this._credential = credential;
this._featuresHeaderValue = featuresHeaderValue;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var token = await this._credential
.GetTokenAsync(s_tokenContext, cancellationToken)
.ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
if (!string.IsNullOrEmpty(this._featuresHeaderValue))
{
request.Headers.TryAddWithoutValidation("Foundry-Features", this._featuresHeaderValue);
}
// MaxRetries is the total number of attempts (not additional retries after the first).
for (int attempt = 0; attempt < MaxRetries; attempt++)
{
// Clone the request for retries (the original request cannot be sent twice)
HttpRequestMessage requestToSend = attempt == 0
? request
: await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false);
var response = await base.SendAsync(requestToSend, cancellationToken).ConfigureAwait(false);
if (response.StatusCode is not (HttpStatusCode.TooManyRequests
or HttpStatusCode.InternalServerError
or HttpStatusCode.BadGateway
or HttpStatusCode.ServiceUnavailable))
{
return response;
}
// Last attempt exhausted — return the error response as-is.
if (attempt == MaxRetries - 1)
{
return response;
}
response.Dispose();
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken)
.ConfigureAwait(false);
}
// Unreachable when MaxRetries > 0, but satisfies the compiler.
throw new InvalidOperationException("Retry loop completed without returning a response.");
}
private static async Task<HttpRequestMessage> CloneRequestAsync(
HttpRequestMessage original,
CancellationToken cancellationToken)
{
var clone = new HttpRequestMessage(original.Method, original.RequestUri);
foreach (var header in original.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
if (original.Content is not null)
{
var contentBytes = await original.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
clone.Content = new ByteArrayContent(contentBytes);
foreach (var header in original.Content.Headers)
{
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
return clone;
}
}
@@ -0,0 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Options for Foundry Toolbox MCP integration.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class FoundryToolboxOptions
{
/// <summary>
/// Gets the list of toolbox names to connect to at startup.
/// Each name corresponds to a toolbox registered in the Foundry project.
/// The platform proxy URL is constructed as:
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version={ApiVersion}</c>
/// </summary>
public IList<string> ToolboxNames { get; } = [];
/// <summary>
/// Gets or sets the Toolsets API version to use when constructing proxy URLs.
/// </summary>
public string ApiVersion { get; set; } = "2025-05-01-preview";
/// <summary>
/// Gets or sets a value indicating whether per-request toolbox markers (referenced via
/// <c>foundry-toolbox://</c> on the wire) are restricted to toolboxes pre-registered
/// via <see cref="ToolboxNames"/>. When <see langword="true"/> (the default), a request
/// that references an unknown toolbox is rejected. When <see langword="false"/>, the
/// server lazily opens an MCP connection for the referenced toolbox on first use and
/// caches it.
/// </summary>
public bool StrictMode { get; set; } = true;
/// <summary>
/// For testing only: overrides <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c>.
/// Not part of the public API.
/// </summary>
internal string? EndpointOverride { get; set; }
}
@@ -0,0 +1,269 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Shared.DiagnosticIds;
using ModelContextProtocol.Client;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// An <see cref="IHostedService"/> that eagerly connects to the Foundry Toolboxes MCP proxy at
/// container startup, discovers tools via <c>tools/list</c>, and caches them so they can be
/// injected into every <see cref="ChatOptions"/> by <see cref="AgentFrameworkResponseHandler"/>.
/// </summary>
/// <remarks>
/// <para>
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent the service starts without error and
/// no tools are registered, keeping the container healthy per spec §2.
/// </para>
/// <para>
/// Startup eagerly connects to every name in <see cref="FoundryToolboxOptions.ToolboxNames"/>.
/// Beyond those, per-request toolbox markers (see <see cref="HostedMcpToolboxAITool"/>) are
/// resolved at request time through <see cref="GetToolboxToolsAsync"/>. Unknown toolboxes are
/// rejected when <see cref="FoundryToolboxOptions.StrictMode"/> is <see langword="true"/> and
/// lazily connected otherwise.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
{
private readonly FoundryToolboxOptions _options;
private readonly TokenCredential _credential;
private readonly ILogger<FoundryToolboxService> _logger;
private readonly Dictionary<string, CachedToolbox> _toolboxes = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _lazyOpenLock = new(1, 1);
private string? _resolvedEndpoint;
private string? _featuresHeader;
private string _agentName = "hosted-agent";
private string _agentVersion = "1.0.0";
/// <summary>
/// Gets the cached list of <see cref="AITool"/> instances discovered from all
/// pre-registered toolboxes. Always non-null after startup.
/// </summary>
public IReadOnlyList<AITool> Tools { get; private set; } = [];
/// <summary>
/// Initializes a new instance of <see cref="FoundryToolboxService"/>.
/// </summary>
public FoundryToolboxService(
IOptions<FoundryToolboxOptions> options,
TokenCredential credential,
ILogger<FoundryToolboxService>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(credential);
this._options = options.Value;
this._credential = credential;
this._logger = logger ?? NullLogger<FoundryToolboxService>.Instance;
}
/// <inheritdoc/>
public async Task StartAsync(CancellationToken cancellationToken)
{
this._resolvedEndpoint = this._options.EndpointOverride
?? Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT");
if (string.IsNullOrEmpty(this._resolvedEndpoint))
{
this._logger.LogInformation("FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set; toolbox support is disabled.");
this.Tools = [];
return;
}
this._featuresHeader = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_FEATURES");
this._agentName = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_NAME") ?? "hosted-agent";
this._agentVersion = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_VERSION") ?? "1.0.0";
if (this._options.ToolboxNames.Count == 0)
{
this._logger.LogInformation("No pre-registered toolbox names configured.");
this.Tools = [];
return;
}
var allTools = new List<AITool>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var toolboxName in this._options.ToolboxNames)
{
if (!seen.Add(toolboxName))
{
continue;
}
try
{
var cached = await this.OpenToolboxAsync(toolboxName, version: null, cancellationToken).ConfigureAwait(false);
this._toolboxes[toolboxName] = cached;
allTools.AddRange(cached.Tools);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
if (this._logger.IsEnabled(LogLevel.Error))
{
this._logger.LogError(
ex,
"Failed to connect to toolbox '{ToolboxName}'. Tools from this toolbox will not be available.",
toolboxName);
}
}
}
this.Tools = allTools;
}
/// <summary>
/// Resolves the tools for a per-request toolbox marker. Returns cached tools when the
/// toolbox has already been opened; otherwise honors
/// <see cref="FoundryToolboxOptions.StrictMode"/> to either reject or lazily open it.
/// </summary>
/// <param name="toolboxName">The Foundry toolbox name from the marker.</param>
/// <param name="version">
/// Optional pinned version. Currently reserved for future use — version-specific routing is
/// handled server-side by the Foundry proxy. This parameter is accepted for forward compatibility
/// but does not affect the proxy URL used to connect to the toolbox.
/// </param>
/// <param name="cancellationToken">The request cancellation token.</param>
/// <exception cref="InvalidOperationException">
/// Thrown when the toolbox is not pre-registered and <see cref="FoundryToolboxOptions.StrictMode"/>
/// is <see langword="true"/>, or when the toolbox endpoint is not configured.
/// </exception>
public async ValueTask<IReadOnlyList<AITool>> GetToolboxToolsAsync(
string toolboxName,
string? version,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(toolboxName);
if (this._toolboxes.TryGetValue(toolboxName, out var cached))
{
return cached.Tools;
}
if (this._options.StrictMode)
{
throw new InvalidOperationException(
$"Toolbox '{toolboxName}' is not pre-registered via AddFoundryToolboxes(...). " +
$"Either register it at startup or set {nameof(FoundryToolboxOptions.StrictMode)}=false to allow lazy resolution.");
}
if (string.IsNullOrEmpty(this._resolvedEndpoint))
{
throw new InvalidOperationException(
$"Cannot resolve toolbox '{toolboxName}': FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set.");
}
await this._lazyOpenLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Double-check after acquiring the lock to avoid duplicate opens under concurrency.
if (this._toolboxes.TryGetValue(toolboxName, out cached))
{
return cached.Tools;
}
cached = await this.OpenToolboxAsync(toolboxName, version, cancellationToken).ConfigureAwait(false);
this._toolboxes[toolboxName] = cached;
return cached.Tools;
}
finally
{
this._lazyOpenLock.Release();
}
}
private async Task<CachedToolbox> OpenToolboxAsync(
string toolboxName,
string? version,
CancellationToken cancellationToken)
{
var proxyUrl = $"{this._resolvedEndpoint!.TrimEnd('/')}/{toolboxName}/mcp?api-version={this._options.ApiVersion}";
if (this._logger.IsEnabled(LogLevel.Information))
{
this._logger.LogInformation("Connecting to toolbox '{ToolboxName}' at {ProxyUrl}.", toolboxName, proxyUrl);
}
var handler = new FoundryToolboxBearerTokenHandler(this._credential, this._featuresHeader)
{
InnerHandler = new HttpClientHandler()
};
var httpClient = new HttpClient(handler);
var transportOptions = new HttpClientTransportOptions
{
Endpoint = new Uri(proxyUrl),
Name = toolboxName,
};
var transport = new HttpClientTransport(transportOptions, httpClient);
var clientOptions = new McpClientOptions
{
ClientInfo = new()
{
Name = this._agentName,
Version = this._agentVersion
}
};
var client = await McpClient.CreateAsync(
transport,
clientOptions,
cancellationToken: cancellationToken).ConfigureAwait(false);
var mcpTools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
if (this._logger.IsEnabled(LogLevel.Information))
{
this._logger.LogInformation(
"Toolbox '{ToolboxName}': discovered {ToolCount} tool(s).",
toolboxName,
mcpTools.Count);
}
var wrapped = new List<AITool>(mcpTools.Count);
foreach (var tool in mcpTools)
{
wrapped.Add(new ConsentAwareMcpClientAIFunction(tool, toolboxName));
}
_ = version; // reserved for future version-specific routing; currently handled server-side by the proxy.
return new CachedToolbox(client, httpClient, wrapped);
}
/// <inheritdoc/>
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
foreach (var cached in this._toolboxes.Values)
{
await cached.Client.DisposeAsync().ConfigureAwait(false);
cached.HttpClient.Dispose();
}
this._toolboxes.Clear();
this._lazyOpenLock.Dispose();
}
private sealed record CachedToolbox(McpClient Client, HttpClient HttpClient, IReadOnlyList<AITool> Tools);
}
@@ -0,0 +1,56 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Provides an in-memory implementation of <see cref="AgentSessionStore"/> for development and testing scenarios.
/// </summary>
/// <remarks>
/// <para>
/// This implementation stores sessions in memory using a concurrent dictionary and is suitable for:
/// <list type="bullet">
/// <item><description>Single-instance development scenarios</description></item>
/// <item><description>Testing and prototyping</description></item>
/// <item><description>Scenarios where session persistence across restarts is not required</description></item>
/// </list>
/// </para>
/// <para>
/// <strong>Warning:</strong> All stored sessions will be lost when the application restarts.
/// For production use with multiple instances or persistence across restarts, use a durable storage implementation
/// such as Redis, SQL Server, or Azure Cosmos DB.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class InMemoryAgentSessionStore : AgentSessionStore
{
private readonly ConcurrentDictionary<string, JsonElement> _sessions = new();
/// <inheritdoc/>
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
{
var key = GetKey(conversationId, agent.Id);
this._sessions[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
{
var key = GetKey(conversationId, agent.Id);
JsonElement? sessionContent = this._sessions.TryGetValue(key, out var existingSession) ? existingSession : null;
return sessionContent switch
{
null => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false),
_ => await agent.DeserializeSessionAsync(sessionContent.Value, cancellationToken: cancellationToken).ConfigureAwait(false),
};
}
private static string GetKey(string conversationId, string agentId) => $"{agentId}:{conversationId}";
}
@@ -0,0 +1,353 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
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.Foundry.Hosting;
/// <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="Item"/> input items into <see cref="ChatMessage"/> instances.
/// </summary>
/// <param name="items">The resolved input items from the SDK context.</param>
/// <returns>A list of chat messages.</returns>
public static List<ChatMessage> ConvertItemsToMessages(IReadOnlyList<Item> items)
{
var messages = new List<ChatMessage>();
foreach (var item in items)
{
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,
// Note: We intentionally do NOT set ModelId from request.Model here.
// The hosted agent already has its own model configured, and passing
// the client-provided model would override it (causing failures when
// clients send placeholder values like "hosted-agent").
};
}
/// <summary>
/// Extracts any Foundry Toolbox markers (<c>foundry-toolbox://</c>) from the request's
/// MCP tool entries so the handler can resolve them server-side.
/// </summary>
/// <param name="request">The create response request.</param>
/// <returns>A list of (name, optional version) pairs, one per detected marker. Never <see langword="null"/>.</returns>
public static List<(string Name, string? Version)> ReadMcpToolboxMarkers(CreateResponse request)
{
var markers = new List<(string Name, string? Version)>();
if (request.Tools is null)
{
return markers;
}
foreach (var tool in request.Tools)
{
if (tool is not MCPTool mcp || mcp.ServerUrl is null)
{
continue;
}
if (HostedMcpToolboxAITool.TryParseToolboxAddress(mcp.ServerUrl.ToString(), out var name, out var version))
{
markers.Add((name!, version));
}
}
return markers;
}
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,45 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Carries OAuth consent information for a single tool call that returned JSON-RPC error -32006.
/// </summary>
/// <param name="ToolboxName">The toolbox name that owns the tool.</param>
/// <param name="ToolName">Fully-qualified tool name (e.g., <c>logicapps.send_email</c>).</param>
/// <param name="ConsentUrl">The OAuth consent URL the user must visit.</param>
internal sealed record McpConsentInfo(string ToolboxName, string ToolName, string ConsentUrl);
/// <summary>
/// Per-request mutable state shared between <see cref="ConsentAwareMcpClientAIFunction"/> (child context)
/// and <see cref="AgentFrameworkResponseHandler"/> (parent context) via <see cref="McpConsentContext.Current"/>.
/// </summary>
/// <remarks>
/// Because <see cref="AsyncLocal{T}"/> only flows values DOWN from parent to children,
/// we use a shared reference type so children can mutate it and the parent observes the mutations.
/// </remarks>
internal sealed class RequestConsentState
{
/// <summary>Consent information set by the tool wrapper when -32006 is detected.</summary>
internal McpConsentInfo? Pending { get; set; }
/// <summary>The linked CTS to cancel when consent is required.</summary>
internal CancellationTokenSource? CancellationSource { get; set; }
}
/// <summary>
/// Async-local context that enables <see cref="ConsentAwareMcpClientAIFunction"/>
/// to signal a consent error back to <see cref="AgentFrameworkResponseHandler"/> through the
/// <see cref="FunctionInvokingChatClient"/> tool loop. Flows with the async ExecutionContext.
/// </summary>
internal static class McpConsentContext
{
/// <summary>
/// Holds the shared <see cref="RequestConsentState"/> for the current request.
/// Set once by the handler; read and mutated by the tool wrapper.
/// </summary>
internal static readonly AsyncLocal<RequestConsentState?> Current = new();
}
@@ -0,0 +1,50 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<RootNamespace>Microsoft.Agents.AI.Foundry.Hosting</RootNamespace>
<VersionSuffix>preview</VersionSuffix>
<Title>Microsoft Agent Framework for Foundry Hosted Agents</Title>
<Description>Provides Microsoft Agent Framework support for hosting Foundry Agents with the Azure AI Agent Service.</Description>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectSharedRedaction>true</InjectSharedRedaction>
<NoWarn>$(NoWarn);OPENAI001;MEAI001;NU1903</NoWarn> <!-- NU1903: Microsoft.Bcl.Memory 9.0.4 transitive vulnerability via Azure SDK; awaiting upstream fix -->
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<!-- Disable package validation baseline until the first release -->
<PropertyGroup>
<PackageValidationBaselineVersion />
<EnablePackageValidation>false</EnablePackageValidation>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.Responses" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Foundry.UnitTests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
</Project>
@@ -0,0 +1,349 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
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.Foundry.Hosting;
/// <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();
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.EmitTextDone(finalText);
yield return textBuilder.EmitDone();
}
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 = details.InputTokenCount ?? 0;
var outputTokens = details.OutputTokenCount ?? 0;
var totalTokens = 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,261 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Threading.Tasks;
using Azure.AI.AgentServer.Responses;
using Azure.Core;
using Azure.Identity;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Extension methods for registering agent-framework agents as Foundry Hosted Agents
/// using the Azure AI Responses Server SDK.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class FoundryHostingExtensions
{
/// <summary>
/// Registers the Azure AI Responses Server SDK and <see cref="AgentFrameworkResponseHandler"/>
/// as the <see cref="ResponseHandler"/>. 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>
/// This method calls <c>AddResponsesServer()</c> internally, so you do not need to
/// call it separately. Register your <see cref="AIAgent"/> instances before calling this.
/// </para>
/// <para>
/// Example:
/// <code>
/// builder.AddAIAgent("my-agent", ...);
/// builder.Services.AddFoundryResponses();
///
/// var app = builder.Build();
/// app.MapFoundryResponses();
/// </code>
/// </para>
/// </remarks>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryResponses(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddResponsesServer();
services.TryAddSingleton<AgentSessionStore, InMemoryAgentSessionStore>();
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
return services;
}
/// <summary>
/// Registers the Azure AI Responses Server SDK and 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 as both a keyed service and the default <see cref="AIAgent"/>.
/// This method calls <c>AddResponsesServer()</c> internally.
/// </para>
/// <para>
/// Example:
/// <code>
/// builder.Services.AddFoundryResponses(myAgent);
///
/// var app = builder.Build();
/// app.MapFoundryResponses();
/// </code>
/// </para>
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="agent">The agent instance to register.</param>
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, an in-memory session store will be used.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(agent);
services.AddResponsesServer();
agentSessionStore ??= new InMemoryAgentSessionStore();
if (!string.IsNullOrWhiteSpace(agent.Name))
{
services.TryAddKeyedSingleton(agent.Name, agent);
services.TryAddKeyedSingleton(agent.Name, agentSessionStore);
}
// Also register as the default (non-keyed) agent so requests
// without an agent name can resolve it (e.g., local dev tooling).
services.TryAddSingleton(agent);
services.TryAddSingleton(agentSessionStore);
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
return services;
}
/// <summary>
/// Registers the Foundry Toolbox service, which eagerly connects to the Foundry Toolboxes
/// MCP proxy at startup and provides MCP tools to <see cref="AgentFrameworkResponseHandler"/>.
/// </summary>
/// <remarks>
/// <para>
/// Each string in <paramref name="toolboxNames"/> is a toolbox name registered in the Foundry
/// project. The proxy URL per toolbox is constructed as:
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version=2025-05-01-preview</c>
/// </para>
/// <para>
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent, startup succeeds without error and
/// no tools are loaded (the container remains healthy per spec §2).
/// </para>
/// <para>
/// Example:
/// <code>
/// builder.Services.AddFoundryToolboxes("my-toolbox", "another-toolbox");
/// </code>
/// </para>
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="toolboxNames">Names of the Foundry toolboxes to connect to.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryToolboxes(
this IServiceCollection services,
params string[] toolboxNames)
=> services.AddFoundryToolboxes(configureOptions: null, toolboxNames);
/// <summary>
/// Registers the Foundry Toolbox service with additional options configuration.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">Callback to further configure <see cref="FoundryToolboxOptions"/> (e.g. set <see cref="FoundryToolboxOptions.StrictMode"/>).</param>
/// <param name="toolboxNames">Names of the Foundry toolboxes to pre-register at startup.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryToolboxes(
this IServiceCollection services,
Action<FoundryToolboxOptions>? configureOptions,
params string[] toolboxNames)
{
ArgumentNullException.ThrowIfNull(services);
services.Configure<FoundryToolboxOptions>(opt =>
{
foreach (var name in toolboxNames)
{
if (!string.IsNullOrWhiteSpace(name))
{
opt.ToolboxNames.Add(name);
}
}
configureOptions?.Invoke(opt);
});
// Register DefaultAzureCredential as the default TokenCredential if not already registered
services.TryAddSingleton<TokenCredential>(_ => new DefaultAzureCredential());
// Register FoundryToolboxService as a singleton so it can be injected into the handler
services.TryAddSingleton<FoundryToolboxService>();
// AddHostedService uses TryAddEnumerable internally, so calling AddFoundryToolboxes
// multiple times will not invoke StartAsync twice on the same singleton.
services.AddHostedService(sp => sp.GetRequiredService<FoundryToolboxService>());
return services;
}
/// <summary>
/// Maps the Responses API routes for the agent-framework handler to the endpoint routing pipeline.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="prefix">Optional route prefix (e.g., "/openai/v1"). Default: empty (routes at /responses).</param>
/// <returns>The endpoint route builder for chaining.</returns>
public static IEndpointRouteBuilder MapFoundryResponses(this IEndpointRouteBuilder endpoints, string prefix = "")
{
ArgumentNullException.ThrowIfNull(endpoints);
endpoints.MapResponsesServer(prefix);
if (endpoints is IApplicationBuilder app)
{
// Ensure the middleware is added to the pipeline
app.UseMiddleware<AgentFrameworkUserAgentMiddleware>();
}
return endpoints;
}
/// <summary>
/// The ActivitySource name for the Responses hosting pipeline.
/// Matches the value previously exposed by <c>AgentHostTelemetry.ResponsesSourceName</c>
/// in <c>Azure.AI.AgentServer.Core</c>.
/// </summary>
private const string ResponsesSourceName = "Azure.AI.AgentServer.Responses";
/// <summary>
/// Wraps <paramref name="agent"/> with <see cref="OpenTelemetryAgent"/> instrumentation
/// so that agent invocations emit spans into the pipeline registered by
/// <c>Azure.AI.AgentServer.Core</c>'s <c>AddAgentHostTelemetry()</c>.
/// If the agent is already instrumented the original instance is returned unchanged.
/// </summary>
internal static AIAgent ApplyOpenTelemetry(AIAgent agent)
{
if (agent.GetService<OpenTelemetryAgent>() is not null)
{
return agent;
}
return agent.AsBuilder()
.UseOpenTelemetry(sourceName: ResponsesSourceName)
.Build();
}
private sealed class AgentFrameworkUserAgentMiddleware(RequestDelegate next)
{
private static readonly string s_userAgentValue = CreateUserAgentValue();
public async Task InvokeAsync(HttpContext context)
{
var headers = context.Request.Headers;
var userAgent = headers.UserAgent.ToString();
if (string.IsNullOrEmpty(userAgent))
{
headers.UserAgent = s_userAgentValue;
}
else if (!userAgent.Contains(s_userAgentValue, StringComparison.OrdinalIgnoreCase))
{
headers.UserAgent = $"{userAgent} {s_userAgentValue}";
}
await next(context).ConfigureAwait(false);
}
private static string CreateUserAgentValue()
{
const string Name = "agent-framework-dotnet";
if (typeof(AgentFrameworkUserAgentMiddleware).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
{
int pos = version.IndexOf('+');
if (pos >= 0)
{
version = version.Substring(0, pos);
}
if (version.Length > 0)
{
return $"{Name}/{version}";
}
}
return Name;
}
}
}
@@ -181,7 +181,7 @@ public static partial class AzureAIProjectChatClientExtensions
/// Creates a non-versioned <see cref="ChatClientAgent"/> backed by the project's Responses API using the specified options.
/// </summary>
/// <param name="aiProjectClient">The <see cref="AIProjectClient"/> to use for Responses API calls. Cannot be <see langword="null"/>.</param>
/// <param name="options">Configuration options that control the agent's behavior. <see cref="ChatOptions.ModelId"/> is required.</param>
/// <param name="options">Optional configuration options that control the agent's behavior.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="loggerFactory">Optional logger factory for creating loggers used by the agent.</param>
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
@@ -190,15 +190,14 @@ public static partial class AzureAIProjectChatClientExtensions
/// <exception cref="ArgumentException">Thrown when <paramref name="options"/> does not specify <see cref="ChatOptions.ModelId"/>.</exception>
public static ChatClientAgent AsAIAgent(
this AIProjectClient aiProjectClient,
ChatClientAgentOptions options,
ChatClientAgentOptions? options = null,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null,
IServiceProvider? services = null)
{
Throw.IfNull(aiProjectClient);
Throw.IfNull(options);
return CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services);
return CreateResponsesChatClientAgent(aiProjectClient, options ?? new(), clientFactory, loggerFactory, services);
}
#region Private
@@ -0,0 +1,307 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// Converts MEAI <see cref="ChatMessage"/> objects to the Foundry evaluator JSON format.
/// </summary>
/// <remarks>
/// Handles the type gap between MEAI's <see cref="ChatMessage"/> / <see cref="AIContent"/> types
/// and the OpenAI-style agent message schema used by Foundry evaluation providers.
/// </remarks>
internal static class FoundryEvalConverter
{
/// <summary>
/// Converts a single <see cref="ChatMessage"/> to one or more Foundry evaluator wire messages.
/// </summary>
/// <remarks>
/// A single message with multiple <see cref="FunctionResultContent"/> entries produces
/// multiple output messages (one per tool result), matching the Foundry evaluator schema.
/// </remarks>
internal static List<WireMessage> ConvertMessage(ChatMessage message)
{
var role = message.Role.Value;
var contentItems = new List<WireContentItem>();
var toolResults = new List<(string CallId, object Result)>();
foreach (var content in message.Contents)
{
switch (content)
{
case TextContent tc when !string.IsNullOrEmpty(tc.Text):
contentItems.Add(new WireTextContent { Text = tc.Text });
break;
case UriContent uc when uc.HasTopLevelMediaType("image"):
contentItems.Add(new WireImageContent { ImageUrl = uc.Uri.ToString() });
break;
case DataContent dc when dc.HasTopLevelMediaType("image"):
contentItems.Add(new WireImageContent { ImageUrl = dc.Uri });
break;
case FunctionCallContent fc:
contentItems.Add(new WireToolCallContent
{
ToolCallId = fc.CallId ?? string.Empty,
Name = fc.Name ?? string.Empty,
Arguments = fc.Arguments is { Count: > 0 } ? fc.Arguments : null,
});
break;
case FunctionResultContent fr:
toolResults.Add((fr.CallId ?? string.Empty, fr.Result ?? string.Empty));
break;
}
}
var output = new List<WireMessage>();
if (toolResults.Count > 0)
{
// Tool results take precedence — the Foundry Evals API expects tool messages
// to have role=tool with a single tool_result content. Any text content in the
// same message is omitted since the API format doesn't support mixed content.
foreach (var (callId, result) in toolResults)
{
output.Add(new WireMessage
{
Role = "tool",
ToolCallId = callId,
Content = [new WireToolResultContent { ToolResult = result }],
});
}
}
else if (contentItems.Count > 0)
{
output.Add(new WireMessage
{
Role = role,
Content = contentItems,
});
}
else
{
output.Add(new WireMessage
{
Role = role,
Content = [new WireTextContent { Text = string.Empty }],
});
}
return output;
}
/// <summary>
/// Converts a sequence of <see cref="ChatMessage"/> objects to Foundry evaluator format.
/// </summary>
internal static List<WireMessage> ConvertMessages(IEnumerable<ChatMessage> messages)
{
var result = new List<WireMessage>();
foreach (var msg in messages)
{
result.AddRange(ConvertMessage(msg));
}
return result;
}
/// <summary>
/// Converts an <see cref="EvalItem"/> to a wire-format payload for the Foundry Evals API.
/// </summary>
/// <remarks>
/// Produces both string fields (query, response) for quality evaluators and
/// conversation arrays (query_messages, response_messages) for agent evaluators.
/// </remarks>
internal static WireEvalItemPayload ConvertEvalItem(EvalItem item, IConversationSplitter? defaultSplitter = null)
{
var splitter = item.Splitter ?? defaultSplitter ?? ConversationSplitters.LastTurn;
var (queryMessages, responseMessages) = splitter.Split(item.Conversation);
return new WireEvalItemPayload
{
Query = item.Query,
Response = item.Response,
QueryMessages = ConvertMessages(queryMessages),
ResponseMessages = ConvertMessages(responseMessages),
Context = item.Context,
ToolDefinitions = item.Tools is { Count: > 0 }
? item.Tools
.OfType<AIFunction>()
.Select(t => new WireToolDefinition
{
Name = t.Name,
Description = t.Description,
Parameters = t.JsonSchema,
})
.ToList()
: null,
};
}
/// <summary>
/// Builds the <c>testing_criteria</c> array for <c>evals.create()</c>.
/// </summary>
/// <param name="evaluators">Evaluator names (short or fully-qualified).</param>
/// <param name="model">Model deployment name for the LLM judge.</param>
/// <param name="includeDataMapping">
/// Whether to include field-level data mapping (required for JSONL data source).
/// </param>
internal static List<WireTestingCriterion> BuildTestingCriteria(
IEnumerable<string> evaluators,
string model,
bool includeDataMapping = false)
{
var criteria = new List<WireTestingCriterion>();
foreach (var name in evaluators)
{
var qualified = ResolveEvaluator(name);
var shortName = name.StartsWith("builtin.", StringComparison.Ordinal)
? name.Substring("builtin.".Length)
: name;
Dictionary<string, string>? dataMapping = null;
if (includeDataMapping)
{
dataMapping = new Dictionary<string, string>();
if (AgentEvaluators.Contains(qualified))
{
dataMapping["query"] = "{{item.query_messages}}";
dataMapping["response"] = "{{item.response_messages}}";
}
else
{
dataMapping["query"] = "{{item.query}}";
dataMapping["response"] = "{{item.response}}";
}
if (qualified == "builtin.groundedness")
{
dataMapping["context"] = "{{item.context}}";
}
if (ToolEvaluators.Contains(qualified))
{
dataMapping["tool_definitions"] = "{{item.tool_definitions}}";
}
}
criteria.Add(new WireTestingCriterion
{
Name = shortName,
EvaluatorName = qualified,
InitializationParameters = new WireInitParams { DeploymentName = model },
DataMapping = dataMapping,
});
}
return criteria;
}
/// <summary>
/// Builds the <c>item_schema</c> for custom JSONL eval definitions.
/// </summary>
internal static WireItemSchema BuildItemSchema(bool hasContext = false, bool hasTools = false)
{
var properties = new Dictionary<string, WireSchemaProperty>
{
["query"] = new() { Type = "string" },
["response"] = new() { Type = "string" },
["query_messages"] = new() { Type = "array" },
["response_messages"] = new() { Type = "array" },
};
if (hasContext)
{
properties["context"] = new WireSchemaProperty { Type = "string" };
}
if (hasTools)
{
properties["tool_definitions"] = new WireSchemaProperty { Type = "array" };
}
return new WireItemSchema
{
Properties = properties,
Required = ["query", "response"],
};
}
/// <summary>
/// Resolves a short evaluator name to its fully-qualified <c>builtin.*</c> form.
/// </summary>
internal static string ResolveEvaluator(string name)
{
if (name.StartsWith("builtin.", StringComparison.OrdinalIgnoreCase))
{
return name;
}
if (BuiltinEvaluators.TryGetValue(name, out var qualified))
{
return qualified;
}
throw new ArgumentException(
$"Unknown evaluator '{name}'. Available: {string.Join(", ", BuiltinEvaluators.Keys.Order())}",
nameof(name));
}
// Agent evaluators that accept query/response as conversation arrays.
internal static readonly HashSet<string> AgentEvaluators = new(StringComparer.OrdinalIgnoreCase)
{
"builtin.intent_resolution",
"builtin.task_adherence",
"builtin.task_completion",
"builtin.task_navigation_efficiency",
"builtin.tool_call_accuracy",
"builtin.tool_selection",
"builtin.tool_input_accuracy",
"builtin.tool_output_utilization",
"builtin.tool_call_success",
};
// Evaluators that additionally require tool_definitions.
internal static readonly HashSet<string> ToolEvaluators = new(StringComparer.OrdinalIgnoreCase)
{
"builtin.tool_call_accuracy",
"builtin.tool_selection",
"builtin.tool_input_accuracy",
"builtin.tool_output_utilization",
"builtin.tool_call_success",
};
// Short name → fully-qualified name mapping.
internal static readonly Dictionary<string, string> BuiltinEvaluators = new(StringComparer.OrdinalIgnoreCase)
{
// Agent behavior
["intent_resolution"] = "builtin.intent_resolution",
["task_adherence"] = "builtin.task_adherence",
["task_completion"] = "builtin.task_completion",
["task_navigation_efficiency"] = "builtin.task_navigation_efficiency",
// Tool usage
["tool_call_accuracy"] = "builtin.tool_call_accuracy",
["tool_selection"] = "builtin.tool_selection",
["tool_input_accuracy"] = "builtin.tool_input_accuracy",
["tool_output_utilization"] = "builtin.tool_output_utilization",
["tool_call_success"] = "builtin.tool_call_success",
// Quality
["coherence"] = "builtin.coherence",
["fluency"] = "builtin.fluency",
["relevance"] = "builtin.relevance",
["groundedness"] = "builtin.groundedness",
["response_completeness"] = "builtin.response_completeness",
["similarity"] = "builtin.similarity",
// Safety
["violence"] = "builtin.violence",
["sexual"] = "builtin.sexual",
["self_harm"] = "builtin.self_harm",
["hate_unfairness"] = "builtin.hate_unfairness",
};
}
@@ -0,0 +1,314 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// Internal wire-format models for the OpenAI Evals API.
/// </summary>
/// <remarks>
/// <para>
/// The OpenAI .NET SDK (as of 2.9.1) marks its <c>EvaluationClient</c> as experimental
/// and exposes only protocol-level methods that accept <c>BinaryContent</c> and return
/// <c>ClientResult</c> — no strongly typed request or response models are provided.
/// </para>
/// <para>
/// These internal models replace hand-built <c>Dictionary&lt;string, object&gt;</c> payloads
/// with compile-timesafe types that are serialized via <see cref="System.Text.Json"/>.
/// When the SDK ships typed models, these should be replaced.
/// </para>
/// </remarks>
// -----------------------------------------------------------------------
// Message content items (polymorphic by "type" discriminator)
// -----------------------------------------------------------------------
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(WireTextContent), "text")]
[JsonDerivedType(typeof(WireImageContent), "input_image")]
[JsonDerivedType(typeof(WireToolCallContent), "tool_call")]
[JsonDerivedType(typeof(WireToolResultContent), "tool_result")]
internal abstract class WireContentItem
{
}
internal sealed class WireTextContent : WireContentItem
{
[JsonPropertyName("text")]
public required string Text { get; init; }
}
internal sealed class WireImageContent : WireContentItem
{
[JsonPropertyName("image_url")]
public required string ImageUrl { get; init; }
[JsonPropertyName("detail")]
public string Detail { get; init; } = "auto";
}
internal sealed class WireToolCallContent : WireContentItem
{
[JsonPropertyName("tool_call_id")]
public required string ToolCallId { get; init; }
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("arguments")]
public IDictionary<string, object?>? Arguments { get; init; }
}
internal sealed class WireToolResultContent : WireContentItem
{
[JsonPropertyName("tool_result")]
public required object ToolResult { get; init; }
}
// -----------------------------------------------------------------------
// Message
// -----------------------------------------------------------------------
internal sealed class WireMessage
{
[JsonPropertyName("role")]
public required string Role { get; init; }
[JsonPropertyName("content")]
public required List<WireContentItem> Content { get; init; }
[JsonPropertyName("tool_call_id")]
public string? ToolCallId { get; init; }
}
// -----------------------------------------------------------------------
// Eval item payload (a single JSONL row sent to the Evals API)
// -----------------------------------------------------------------------
internal sealed class WireEvalItemPayload
{
[JsonPropertyName("query")]
public required string Query { get; init; }
[JsonPropertyName("response")]
public required string Response { get; init; }
[JsonPropertyName("query_messages")]
public required List<WireMessage> QueryMessages { get; init; }
[JsonPropertyName("response_messages")]
public required List<WireMessage> ResponseMessages { get; init; }
[JsonPropertyName("context")]
public string? Context { get; init; }
[JsonPropertyName("tool_definitions")]
public List<WireToolDefinition>? ToolDefinitions { get; init; }
}
internal sealed class WireToolDefinition
{
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("parameters")]
public object? Parameters { get; init; }
}
// -----------------------------------------------------------------------
// Testing criteria (evaluator definitions within an eval)
// -----------------------------------------------------------------------
internal sealed class WireTestingCriterion
{
[JsonPropertyName("type")]
public string Type { get; init; } = "azure_ai_evaluator";
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("evaluator_name")]
public required string EvaluatorName { get; init; }
[JsonPropertyName("initialization_parameters")]
public required WireInitParams InitializationParameters { get; init; }
[JsonPropertyName("data_mapping")]
public Dictionary<string, string>? DataMapping { get; init; }
}
internal sealed class WireInitParams
{
[JsonPropertyName("deployment_name")]
public required string DeploymentName { get; init; }
}
// -----------------------------------------------------------------------
// Item schema (for custom JSONL data source definitions)
// -----------------------------------------------------------------------
internal sealed class WireItemSchema
{
[JsonPropertyName("type")]
public string Type { get; init; } = "object";
[JsonPropertyName("properties")]
public required Dictionary<string, WireSchemaProperty> Properties { get; init; }
[JsonPropertyName("required")]
public required List<string> Required { get; init; }
}
internal sealed class WireSchemaProperty
{
[JsonPropertyName("type")]
public required string Type { get; init; }
}
// -----------------------------------------------------------------------
// Create evaluation request
// -----------------------------------------------------------------------
internal sealed class WireCreateEvalRequest
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("data_source_config")]
public required object DataSourceConfig { get; init; }
[JsonPropertyName("testing_criteria")]
public required List<WireTestingCriterion> TestingCriteria { get; init; }
}
// Data source configuration variants
internal sealed class WireCustomDataSourceConfig
{
[JsonPropertyName("type")]
public string Type { get; init; } = "custom";
[JsonPropertyName("item_schema")]
public required WireItemSchema ItemSchema { get; init; }
[JsonPropertyName("include_sample_schema")]
public bool IncludeSampleSchema { get; init; } = true;
}
internal sealed class WireAzureAiDataSourceConfig
{
[JsonPropertyName("type")]
public string Type { get; init; } = "azure_ai_source";
[JsonPropertyName("scenario")]
public required string Scenario { get; init; }
}
// -----------------------------------------------------------------------
// Create evaluation run request
// -----------------------------------------------------------------------
internal sealed class WireCreateRunRequest
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("data_source")]
public required object DataSource { get; init; }
}
// -----------------------------------------------------------------------
// Data source variants (used in run requests)
// -----------------------------------------------------------------------
internal sealed class WireJsonlDataSource
{
[JsonPropertyName("type")]
public string Type { get; init; } = "jsonl";
[JsonPropertyName("source")]
public required WireFileContentSource Source { get; init; }
}
internal sealed class WireFileContentSource
{
[JsonPropertyName("type")]
public string Type { get; init; } = "file_content";
[JsonPropertyName("content")]
public required List<WireItemWrapper> Content { get; init; }
}
internal sealed class WireItemWrapper
{
[JsonPropertyName("item")]
public required object Item { get; init; }
}
internal sealed class WireResponsesDataSource
{
[JsonPropertyName("type")]
public string Type { get; init; } = "azure_ai_responses";
[JsonPropertyName("item_generation_params")]
public required WireResponseRetrievalParams ItemGenerationParams { get; init; }
}
internal sealed class WireResponseRetrievalParams
{
[JsonPropertyName("type")]
public string Type { get; init; } = "response_retrieval";
[JsonPropertyName("data_mapping")]
public required Dictionary<string, string> DataMapping { get; init; }
[JsonPropertyName("source")]
public required WireFileContentSource Source { get; init; }
}
internal sealed class WireTracesDataSource
{
[JsonPropertyName("type")]
public string Type { get; init; } = "azure_ai_traces";
[JsonPropertyName("lookback_hours")]
public int LookbackHours { get; init; }
[JsonPropertyName("trace_ids")]
public List<string>? TraceIds { get; init; }
[JsonPropertyName("agent_id")]
public string? AgentId { get; init; }
}
internal sealed class WireTargetCompletionsDataSource
{
[JsonPropertyName("type")]
public string Type { get; init; } = "azure_ai_target_completions";
[JsonPropertyName("target")]
public required IDictionary<string, object> Target { get; init; }
[JsonPropertyName("source")]
public required WireFileContentSource Source { get; init; }
}
// -----------------------------------------------------------------------
// Small item payloads used inside WireItemWrapper
// -----------------------------------------------------------------------
internal sealed class WireResponseIdItem
{
[JsonPropertyName("resp_id")]
public required string RespId { get; init; }
}
internal sealed class WireQueryItem
{
[JsonPropertyName("query")]
public required string Query { get; init; }
}
@@ -0,0 +1,920 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Microsoft.Extensions.AI.Evaluation;
using OpenAI.Evals;
#pragma warning disable OPENAI001 // EvaluationClient is experimental
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// Azure AI Foundry evaluator provider that calls the Foundry Evals API.
/// </summary>
/// <remarks>
/// <para>
/// Uses the OpenAI Evals API (<c>evals.create</c> / <c>evals.runs.create</c>) via the
/// project endpoint to run evaluations server-side. All built-in Foundry evaluators
/// (quality, safety, agent behavior, tool usage) are supported.
/// </para>
/// <para>
/// Results appear in the Azure AI Foundry portal with a report URL for detailed analysis.
/// </para>
/// </remarks>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing Dictionary<string, object> for eval API payloads.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing Dictionary<string, object> for eval API payloads.")]
public sealed class FoundryEvals : IAgentEvaluator
{
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
};
private readonly EvaluationClient _evaluationClient;
private readonly string _model;
private readonly string[] _evaluatorNames;
private readonly IConversationSplitter? _splitter;
private readonly double _pollIntervalSeconds = 5.0;
private readonly double _timeoutSeconds = 300.0;
// -----------------------------------------------------------------------
// Constructors
// -----------------------------------------------------------------------
/// <summary>
/// Initializes a new instance of the <see cref="FoundryEvals"/> class.
/// </summary>
/// <param name="projectClient">The Azure AI Foundry project client.</param>
/// <param name="model">Model deployment name for the LLM judge evaluator.</param>
/// <param name="evaluators">
/// Names of evaluators to use (e.g., <see cref="Relevance"/>, <see cref="Coherence"/>).
/// When empty, defaults to relevance and coherence.
/// </param>
public FoundryEvals(AIProjectClient projectClient, string model, params string[] evaluators)
{
ArgumentNullException.ThrowIfNull(projectClient);
ArgumentException.ThrowIfNullOrWhiteSpace(model);
this._evaluationClient = projectClient.GetProjectOpenAIClient().GetEvaluationClient();
this._model = model;
this._evaluatorNames = evaluators.Length > 0
? evaluators
: [Relevance, Coherence, TaskAdherence];
}
/// <summary>
/// Initializes a new instance of the <see cref="FoundryEvals"/> class with a conversation splitter.
/// </summary>
/// <param name="projectClient">The Azure AI Foundry project client.</param>
/// <param name="model">Model deployment name for the LLM judge evaluator.</param>
/// <param name="splitter">
/// Default conversation splitter for multi-turn conversations.
/// Use <see cref="ConversationSplitters.LastTurn"/>, <see cref="ConversationSplitters.Full"/>,
/// or a custom <see cref="IConversationSplitter"/> implementation.
/// </param>
/// <param name="evaluators">
/// Names of evaluators to use (e.g., <see cref="Relevance"/>, <see cref="Coherence"/>).
/// When empty, defaults to relevance and coherence.
/// </param>
public FoundryEvals(
AIProjectClient projectClient,
string model,
IConversationSplitter? splitter,
params string[] evaluators)
: this(projectClient, model, evaluators)
{
this._splitter = splitter;
}
/// <summary>
/// Initializes a new instance of the <see cref="FoundryEvals"/> class with full configuration.
/// </summary>
/// <param name="projectClient">The Azure AI Foundry project client.</param>
/// <param name="model">Model deployment name for the LLM judge evaluator.</param>
/// <param name="splitter">
/// Default conversation splitter for multi-turn conversations.
/// </param>
/// <param name="pollIntervalSeconds">Seconds between status polls (default 5).</param>
/// <param name="timeoutSeconds">Maximum seconds to wait for completion (default 300).</param>
/// <param name="evaluators">Evaluator names to use.</param>
public FoundryEvals(
AIProjectClient projectClient,
string model,
IConversationSplitter? splitter,
double pollIntervalSeconds,
double timeoutSeconds,
params string[] evaluators)
: this(projectClient, model, splitter, evaluators)
{
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(pollIntervalSeconds, 0);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(timeoutSeconds, 0);
this._pollIntervalSeconds = pollIntervalSeconds;
this._timeoutSeconds = timeoutSeconds;
}
// -----------------------------------------------------------------------
// IAgentEvaluator
// -----------------------------------------------------------------------
/// <inheritdoc />
public string Name => "FoundryEvals";
/// <inheritdoc />
public async Task<AgentEvaluationResults> EvaluateAsync(
IReadOnlyList<EvalItem> items,
string evalName = "Agent Framework Eval",
CancellationToken cancellationToken = default)
{
// 1. Convert EvalItems to typed payloads
var payloads = new List<WireEvalItemPayload>(items.Count);
foreach (var item in items)
{
payloads.Add(FoundryEvalConverter.ConvertEvalItem(item, this._splitter));
}
bool hasContext = payloads.Any(p => p.Context is not null);
bool hasTools = payloads.Any(p => p.ToolDefinitions is { Count: > 0 });
// Filter out tool evaluators if no items have tools; auto-add ToolCallAccuracy if tools present
var evaluators = FilterToolEvaluators(this._evaluatorNames, hasTools);
if (hasTools && !evaluators.Any(e => FoundryEvalConverter.ToolEvaluators.Contains(FoundryEvalConverter.ResolveEvaluator(e))))
{
evaluators = [.. evaluators, ToolCallAccuracy];
}
// 2. Create the evaluation definition
var createEvalPayload = new WireCreateEvalRequest
{
Name = evalName,
DataSourceConfig = new WireCustomDataSourceConfig
{
ItemSchema = FoundryEvalConverter.BuildItemSchema(hasContext, hasTools),
},
TestingCriteria = FoundryEvalConverter.BuildTestingCriteria(
evaluators, this._model, includeDataMapping: true),
};
var createEvalJson = JsonSerializer.Serialize(createEvalPayload, s_jsonOptions);
var createEvalResult = await this._evaluationClient.CreateEvaluationAsync(
BinaryContent.Create(BinaryData.FromString(createEvalJson)),
new RequestOptions { CancellationToken = cancellationToken }).ConfigureAwait(false);
string evalId;
using (var evalResponse = JsonDocument.Parse(createEvalResult.GetRawResponse().Content))
{
evalId = evalResponse.RootElement.GetProperty("id").GetString()
?? throw new InvalidOperationException("Foundry eval creation returned a null ID.");
}
// 3. Create the evaluation run with inline JSONL data
var createRunPayload = new WireCreateRunRequest
{
Name = $"{evalName} Run",
DataSource = new WireJsonlDataSource
{
Source = new WireFileContentSource
{
Content = payloads.ConvertAll(p => new WireItemWrapper { Item = p }),
},
},
};
var createRunJson = JsonSerializer.Serialize(createRunPayload, s_jsonOptions);
var createRunResult = await this._evaluationClient.CreateEvaluationRunAsync(
evalId,
BinaryContent.Create(BinaryData.FromString(createRunJson)),
new RequestOptions { CancellationToken = cancellationToken }).ConfigureAwait(false);
string runId;
using (var runResponse = JsonDocument.Parse(createRunResult.GetRawResponse().Content))
{
runId = runResponse.RootElement.GetProperty("id").GetString()
?? throw new InvalidOperationException("Foundry eval run creation returned a null run ID.");
}
// 4. Poll until complete
var pollResult = await this.PollEvalRunAsync(evalId, runId, cancellationToken).ConfigureAwait(false);
if (pollResult.Status is "failed" or "canceled")
{
throw new InvalidOperationException(
$"Foundry evaluation run {runId} {pollResult.Status}: {pollResult.ErrorMessage ?? "no details available"}");
}
if (pollResult.Status == "timeout")
{
throw new TimeoutException(
$"Foundry evaluation run {runId} did not complete within {this._timeoutSeconds}s. " +
"Increase timeoutSeconds or check the run status in the Foundry portal.");
}
// 5. Fetch output items and build results
var fetchResult = await this.FetchOutputItemResultsAsync(evalId, runId, cancellationToken).ConfigureAwait(false);
// Pad MEAI results if we got fewer than items (e.g. partial output)
if (fetchResult.MeaiResults.Count < items.Count)
{
Trace.TraceWarning(
"Foundry returned {0} result(s) but {1} item(s) were submitted. " +
"Padding {2} missing item(s) with empty results — these items will count as failed.",
fetchResult.MeaiResults.Count,
items.Count,
items.Count - fetchResult.MeaiResults.Count);
}
while (fetchResult.MeaiResults.Count < items.Count)
{
fetchResult.MeaiResults.Add(new EvaluationResult());
}
return new AgentEvaluationResults(this.Name, fetchResult.MeaiResults, inputItems: items)
{
ReportUrl = pollResult.ReportUrl is not null ? new Uri(pollResult.ReportUrl) : null,
EvalId = evalId,
RunId = runId,
Status = pollResult.Status,
Error = pollResult.ErrorMessage,
PerEvaluator = pollResult.PerEvaluator,
DetailedItems = fetchResult.DetailedItems,
};
}
// -----------------------------------------------------------------------
// Static evaluation methods (traces and targets)
// -----------------------------------------------------------------------
/// <summary>
/// Evaluates agent behavior from Responses API response IDs, OTel traces, or agent activity.
/// </summary>
/// <remarks>
/// <para>
/// Foundry-specific method that works with any agent emitting OTel traces to App Insights.
/// Provide <paramref name="responseIds"/> for specific Responses API responses,
/// <paramref name="traceIds"/> for specific traces, or <paramref name="agentId"/> with
/// <paramref name="lookbackHours"/> to evaluate recent activity.
/// </para>
/// </remarks>
/// <param name="projectClient">The Azure AI Foundry project client.</param>
/// <param name="model">Model deployment name for the LLM judge evaluator.</param>
/// <param name="responseIds">Evaluate specific Responses API response IDs.</param>
/// <param name="traceIds">Evaluate specific OTel trace IDs from App Insights.</param>
/// <param name="agentId">Filter traces by agent ID (used with <paramref name="lookbackHours"/>).</param>
/// <param name="lookbackHours">Hours of trace history to evaluate (default 24).</param>
/// <param name="evaluators">Evaluator names. Defaults to relevance, coherence, and task adherence.</param>
/// <param name="evalName">Display name for the evaluation.</param>
/// <param name="pollIntervalSeconds">Seconds between status polls (default 5).</param>
/// <param name="timeoutSeconds">Maximum seconds to wait for completion (default 300).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Evaluation results with status, report URL, and per-item details.</returns>
public static async Task<AgentEvaluationResults> EvaluateTracesAsync(
AIProjectClient projectClient,
string model,
IEnumerable<string>? responseIds = null,
IEnumerable<string>? traceIds = null,
string? agentId = null,
int lookbackHours = 24,
string[]? evaluators = null,
string evalName = "Agent Framework Trace Eval",
double pollIntervalSeconds = 5.0,
double timeoutSeconds = 300.0,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(projectClient);
ArgumentException.ThrowIfNullOrWhiteSpace(model);
var responseIdList = responseIds?.ToList();
var traceIdList = traceIds?.ToList();
if ((responseIdList is null || responseIdList.Count == 0)
&& (traceIdList is null || traceIdList.Count == 0)
&& string.IsNullOrEmpty(agentId))
{
throw new ArgumentException("Provide at least one of: responseIds, traceIds, or agentId.");
}
var evalClient = projectClient.GetProjectOpenAIClient().GetEvaluationClient();
var resolvedEvaluators = evaluators is { Length: > 0 }
? evaluators
: [Relevance, Coherence, TaskAdherence];
// Create the evaluation definition with the appropriate data source scenario
object dataSourceConfig;
object runDataSource;
if (responseIdList is { Count: > 0 })
{
// Responses API path
dataSourceConfig = new WireAzureAiDataSourceConfig { Scenario = "responses" };
runDataSource = new WireResponsesDataSource
{
ItemGenerationParams = new WireResponseRetrievalParams
{
DataMapping = new Dictionary<string, string> { ["response_id"] = "{{item.resp_id}}" },
Source = new WireFileContentSource
{
Content = responseIdList.ConvertAll(id => new WireItemWrapper
{
Item = new WireResponseIdItem { RespId = id },
}),
},
},
};
}
else
{
// Traces path
dataSourceConfig = new WireAzureAiDataSourceConfig { Scenario = "traces" };
runDataSource = new WireTracesDataSource
{
LookbackHours = lookbackHours,
TraceIds = traceIdList is { Count: > 0 } ? traceIdList : null,
AgentId = !string.IsNullOrEmpty(agentId) ? agentId : null,
};
}
var createEvalPayload = new WireCreateEvalRequest
{
Name = evalName,
DataSourceConfig = dataSourceConfig,
TestingCriteria = FoundryEvalConverter.BuildTestingCriteria(resolvedEvaluators, model),
};
var createEvalJson = JsonSerializer.Serialize(createEvalPayload, s_jsonOptions);
var createEvalResult = await evalClient.CreateEvaluationAsync(
BinaryContent.Create(BinaryData.FromString(createEvalJson)),
new RequestOptions { CancellationToken = cancellationToken }).ConfigureAwait(false);
string evalId;
using (var evalResponse = JsonDocument.Parse(createEvalResult.GetRawResponse().Content))
{
evalId = evalResponse.RootElement.GetProperty("id").GetString()
?? throw new InvalidOperationException("Foundry eval creation returned a null ID.");
}
var createRunPayload = new WireCreateRunRequest
{
Name = $"{evalName} Run",
DataSource = runDataSource,
};
var createRunJson = JsonSerializer.Serialize(createRunPayload, s_jsonOptions);
var createRunResult = await evalClient.CreateEvaluationRunAsync(
evalId,
BinaryContent.Create(BinaryData.FromString(createRunJson)),
new RequestOptions { CancellationToken = cancellationToken }).ConfigureAwait(false);
string runId;
using (var runResponse = JsonDocument.Parse(createRunResult.GetRawResponse().Content))
{
runId = runResponse.RootElement.GetProperty("id").GetString()
?? throw new InvalidOperationException("Foundry eval run creation returned a null run ID.");
}
// Poll and fetch
var instance = new FoundryEvals(projectClient, model, null, pollIntervalSeconds, timeoutSeconds, resolvedEvaluators);
var pollResult = await instance.PollEvalRunAsync(evalId, runId, cancellationToken).ConfigureAwait(false);
if (pollResult.Status is "failed" or "canceled")
{
throw new InvalidOperationException(
$"Foundry trace evaluation run {runId} {pollResult.Status}: {pollResult.ErrorMessage ?? "no details available"}");
}
if (pollResult.Status == "timeout")
{
throw new TimeoutException(
$"Foundry trace evaluation run {runId} did not complete within {timeoutSeconds}s.");
}
var fetchResult = await instance.FetchOutputItemResultsAsync(evalId, runId, cancellationToken).ConfigureAwait(false);
return new AgentEvaluationResults("FoundryEvals", fetchResult.MeaiResults)
{
ReportUrl = pollResult.ReportUrl is not null ? new Uri(pollResult.ReportUrl) : null,
EvalId = evalId,
RunId = runId,
Status = pollResult.Status,
Error = pollResult.ErrorMessage,
PerEvaluator = pollResult.PerEvaluator,
DetailedItems = fetchResult.DetailedItems,
};
}
/// <summary>
/// Evaluates a Foundry-registered agent or model deployment.
/// </summary>
/// <remarks>
/// Foundry invokes the target, captures the output, and evaluates it.
/// Use this for scheduled evaluations, red teaming, and CI/CD quality gates.
/// </remarks>
/// <param name="projectClient">The Azure AI Foundry project client.</param>
/// <param name="model">Model deployment name for the LLM judge evaluator.</param>
/// <param name="target">Target configuration (must include a "type" key, e.g. "azure_ai_agent").</param>
/// <param name="testQueries">Queries for Foundry to send to the target.</param>
/// <param name="evaluators">Evaluator names. Defaults to relevance, coherence, and task adherence.</param>
/// <param name="evalName">Display name for the evaluation.</param>
/// <param name="pollIntervalSeconds">Seconds between status polls (default 5).</param>
/// <param name="timeoutSeconds">Maximum seconds to wait for completion (default 300).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Evaluation results with status, report URL, and per-item details.</returns>
public static async Task<AgentEvaluationResults> EvaluateFoundryTargetAsync(
AIProjectClient projectClient,
string model,
IDictionary<string, object> target,
IEnumerable<string> testQueries,
string[]? evaluators = null,
string evalName = "Agent Framework Target Eval",
double pollIntervalSeconds = 5.0,
double timeoutSeconds = 300.0,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(projectClient);
ArgumentException.ThrowIfNullOrWhiteSpace(model);
ArgumentNullException.ThrowIfNull(target);
if (!target.ContainsKey("type"))
{
throw new ArgumentException("Target must include a 'type' key (e.g., 'azure_ai_agent').", nameof(target));
}
var queryList = testQueries.ToList();
if (queryList.Count == 0)
{
throw new ArgumentException("At least one test query is required.", nameof(testQueries));
}
var evalClient = projectClient.GetProjectOpenAIClient().GetEvaluationClient();
var resolvedEvaluators = evaluators is { Length: > 0 }
? evaluators
: [Relevance, Coherence, TaskAdherence];
var createEvalPayload = new WireCreateEvalRequest
{
Name = evalName,
DataSourceConfig = new WireAzureAiDataSourceConfig { Scenario = "target_completions" },
TestingCriteria = FoundryEvalConverter.BuildTestingCriteria(resolvedEvaluators, model),
};
var createEvalJson = JsonSerializer.Serialize(createEvalPayload, s_jsonOptions);
var createEvalResult = await evalClient.CreateEvaluationAsync(
BinaryContent.Create(BinaryData.FromString(createEvalJson)),
new RequestOptions { CancellationToken = cancellationToken }).ConfigureAwait(false);
string evalId;
using (var evalResponse = JsonDocument.Parse(createEvalResult.GetRawResponse().Content))
{
evalId = evalResponse.RootElement.GetProperty("id").GetString()
?? throw new InvalidOperationException("Foundry eval creation returned a null ID.");
}
var createRunPayload = new WireCreateRunRequest
{
Name = $"{evalName} Run",
DataSource = new WireTargetCompletionsDataSource
{
Target = target,
Source = new WireFileContentSource
{
Content = queryList.ConvertAll(q => new WireItemWrapper
{
Item = new WireQueryItem { Query = q },
}),
},
},
};
var createRunJson = JsonSerializer.Serialize(createRunPayload, s_jsonOptions);
var createRunResult = await evalClient.CreateEvaluationRunAsync(
evalId,
BinaryContent.Create(BinaryData.FromString(createRunJson)),
new RequestOptions { CancellationToken = cancellationToken }).ConfigureAwait(false);
string runId;
using (var runResponse = JsonDocument.Parse(createRunResult.GetRawResponse().Content))
{
runId = runResponse.RootElement.GetProperty("id").GetString()
?? throw new InvalidOperationException("Foundry eval run creation returned a null run ID.");
}
var instance = new FoundryEvals(projectClient, model, null, pollIntervalSeconds, timeoutSeconds, resolvedEvaluators);
var pollResult = await instance.PollEvalRunAsync(evalId, runId, cancellationToken).ConfigureAwait(false);
if (pollResult.Status is "failed" or "canceled")
{
throw new InvalidOperationException(
$"Foundry target evaluation run {runId} {pollResult.Status}: {pollResult.ErrorMessage ?? "no details available"}");
}
if (pollResult.Status == "timeout")
{
throw new TimeoutException(
$"Foundry target evaluation run {runId} did not complete within {timeoutSeconds}s.");
}
var fetchResult = await instance.FetchOutputItemResultsAsync(evalId, runId, cancellationToken).ConfigureAwait(false);
return new AgentEvaluationResults("FoundryEvals", fetchResult.MeaiResults)
{
ReportUrl = pollResult.ReportUrl is not null ? new Uri(pollResult.ReportUrl) : null,
EvalId = evalId,
RunId = runId,
Status = pollResult.Status,
Error = pollResult.ErrorMessage,
PerEvaluator = pollResult.PerEvaluator,
DetailedItems = fetchResult.DetailedItems,
};
}
// -----------------------------------------------------------------------
// Evaluator name constants
// -----------------------------------------------------------------------
// Agent behavior
/// <summary>Evaluates whether the agent correctly resolves user intent.</summary>
public const string IntentResolution = "intent_resolution";
/// <summary>Evaluates whether the agent adheres to its task instructions.</summary>
public const string TaskAdherence = "task_adherence";
/// <summary>Evaluates whether the agent completes the requested task.</summary>
public const string TaskCompletion = "task_completion";
/// <summary>Evaluates the efficiency of the agent's navigation to complete the task.</summary>
public const string TaskNavigationEfficiency = "task_navigation_efficiency";
// Tool usage
/// <summary>Evaluates the accuracy of tool calls made by the agent.</summary>
public const string ToolCallAccuracy = "tool_call_accuracy";
/// <summary>Evaluates whether the agent selects the correct tools.</summary>
public const string ToolSelection = "tool_selection";
/// <summary>Evaluates the accuracy of inputs provided to tools.</summary>
public const string ToolInputAccuracy = "tool_input_accuracy";
/// <summary>Evaluates how well the agent uses tool outputs.</summary>
public const string ToolOutputUtilization = "tool_output_utilization";
/// <summary>Evaluates whether tool calls succeed.</summary>
public const string ToolCallSuccess = "tool_call_success";
// Quality
/// <summary>Evaluates the coherence of the response.</summary>
public const string Coherence = "coherence";
/// <summary>Evaluates the fluency of the response.</summary>
public const string Fluency = "fluency";
/// <summary>Evaluates the relevance of the response to the query.</summary>
public const string Relevance = "relevance";
/// <summary>Evaluates whether the response is grounded in the provided context.</summary>
public const string Groundedness = "groundedness";
/// <summary>Evaluates the completeness of the response.</summary>
public const string ResponseCompleteness = "response_completeness";
/// <summary>Evaluates the similarity between the response and the expected output.</summary>
public const string Similarity = "similarity";
// Safety
/// <summary>Evaluates the response for violent content.</summary>
public const string Violence = "violence";
/// <summary>Evaluates the response for sexual content.</summary>
public const string Sexual = "sexual";
/// <summary>Evaluates the response for self-harm content.</summary>
public const string SelfHarm = "self_harm";
/// <summary>Evaluates the response for hate or unfairness.</summary>
public const string HateUnfairness = "hate_unfairness";
// -----------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------
private async Task<PollResult> PollEvalRunAsync(
string evalId,
string runId,
CancellationToken cancellationToken)
{
var deadline = DateTime.UtcNow.AddSeconds(this._timeoutSeconds);
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await this._evaluationClient.GetEvaluationRunAsync(
evalId,
runId,
new RequestOptions { CancellationToken = cancellationToken }).ConfigureAwait(false);
using var runDoc = JsonDocument.Parse(result.GetRawResponse().Content);
var root = runDoc.RootElement;
var status = root.GetProperty("status").GetString()!;
if (status is "completed" or "failed" or "canceled")
{
string? reportUrl = root.TryGetProperty("report_url", out var urlProp) ? urlProp.GetString() : null;
string? errorMessage = root.TryGetProperty("error", out var errProp) ? errProp.ToString() : null;
// Extract per-evaluator breakdown
Dictionary<string, PerEvaluatorResult>? perEvaluator = null;
if (root.TryGetProperty("per_testing_criteria_results", out var criteriaArray)
&& criteriaArray.ValueKind == JsonValueKind.Array)
{
perEvaluator = new Dictionary<string, PerEvaluatorResult>();
foreach (var item in criteriaArray.EnumerateArray())
{
var name = item.TryGetProperty("testing_criteria", out var tcProp)
? tcProp.GetString()
: null;
if (name is not null)
{
int passed = item.TryGetProperty("passed", out var pp) && pp.ValueKind == JsonValueKind.Number
? pp.GetInt32() : 0;
int failed = item.TryGetProperty("failed", out var fp) && fp.ValueKind == JsonValueKind.Number
? fp.GetInt32() : 0;
perEvaluator[name] = new PerEvaluatorResult(passed, failed);
}
}
}
return new PollResult(status, reportUrl, errorMessage, perEvaluator);
}
if (DateTime.UtcNow >= deadline)
{
return new PollResult("timeout", null, null, null);
}
await Task.Delay(TimeSpan.FromSeconds(this._pollIntervalSeconds), cancellationToken).ConfigureAwait(false);
}
}
private sealed record PollResult(
string Status,
string? ReportUrl,
string? ErrorMessage,
Dictionary<string, PerEvaluatorResult>? PerEvaluator);
private async Task<FetchResult> FetchOutputItemResultsAsync(
string evalId,
string runId,
CancellationToken cancellationToken)
{
var meaiResults = new List<EvaluationResult>();
var detailedItems = new List<EvalItemResult>();
string? afterCursor = null;
while (true)
{
var response = await this._evaluationClient.GetEvaluationRunOutputItemsAsync(
evalId,
runId,
limit: 100,
order: null,
after: afterCursor,
outputItemStatus: null,
new RequestOptions { CancellationToken = cancellationToken }).ConfigureAwait(false);
using var doc = JsonDocument.Parse(response.GetRawResponse().Content);
if (doc.RootElement.TryGetProperty("data", out var dataArray))
{
foreach (var outputItem in dataArray.EnumerateArray())
{
meaiResults.Add(ParseOutputItem(outputItem));
detailedItems.Add(ParseDetailedItem(outputItem));
}
}
// Check for more pages
bool hasMore = doc.RootElement.TryGetProperty("has_more", out var hasMoreProp)
&& hasMoreProp.ValueKind == JsonValueKind.True;
if (!hasMore)
{
break;
}
// Get cursor for next page — use last_id or last item's id
if (doc.RootElement.TryGetProperty("last_id", out var lastIdProp))
{
afterCursor = lastIdProp.GetString();
}
else if (doc.RootElement.TryGetProperty("data", out var data2) && data2.GetArrayLength() > 0)
{
var lastItem = data2[data2.GetArrayLength() - 1];
afterCursor = lastItem.TryGetProperty("id", out var idProp) ? idProp.GetString() : null;
}
if (afterCursor is null)
{
break;
}
}
return new FetchResult(meaiResults, detailedItems);
}
private sealed record FetchResult(
List<EvaluationResult> MeaiResults,
List<EvalItemResult> DetailedItems);
private static EvaluationResult ParseOutputItem(JsonElement outputItem)
{
var evalResult = new EvaluationResult();
if (outputItem.TryGetProperty("results", out var itemResults))
{
foreach (var r in itemResults.EnumerateArray())
{
var metricName = r.TryGetProperty("name", out var nameProp)
? nameProp.GetString() ?? "unknown"
: "unknown";
bool? passed = null;
if (r.TryGetProperty("passed", out var passedProp)
&& passedProp.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
passed = passedProp.ValueKind == JsonValueKind.True;
}
double? score = r.TryGetProperty("score", out var scoreProp) && scoreProp.ValueKind == JsonValueKind.Number
? scoreProp.GetDouble()
: null;
EvaluationMetricInterpretation? interpretation = passed.HasValue
? new EvaluationMetricInterpretation
{
Rating = passed.Value ? EvaluationRating.Good : EvaluationRating.Unacceptable,
Failed = !passed.Value,
}
: null;
if (score.HasValue)
{
evalResult.Metrics[metricName] = new NumericMetric(metricName, score.Value)
{
Interpretation = interpretation,
};
}
else if (passed.HasValue)
{
evalResult.Metrics[metricName] = new BooleanMetric(metricName, passed.Value)
{
Interpretation = interpretation,
};
}
// When neither score nor passed is present, the evaluator returned no
// actionable data (e.g. an error or informational entry). Skip the metric
// so it doesn't falsely influence ItemPassed. The raw data is still
// available in DetailedItems for diagnostics.
}
}
return evalResult;
}
private static EvalItemResult ParseDetailedItem(JsonElement outputItem)
{
var itemId = outputItem.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? "" : "";
var status = outputItem.TryGetProperty("status", out var statusProp) ? statusProp.GetString() ?? "" : "";
var scores = new List<EvalScoreResult>();
if (outputItem.TryGetProperty("results", out var itemResults))
{
foreach (var r in itemResults.EnumerateArray())
{
var name = r.TryGetProperty("name", out var np) ? np.GetString() ?? "unknown" : "unknown";
double score = r.TryGetProperty("score", out var sp) && sp.ValueKind == JsonValueKind.Number
? sp.GetDouble() : 0.0;
bool? passed = null;
if (r.TryGetProperty("passed", out var pp) && pp.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
passed = pp.ValueKind == JsonValueKind.True;
}
scores.Add(new EvalScoreResult(name, score, passed));
}
}
var result = new EvalItemResult(itemId, status, scores);
// Extract error info from sample
if (outputItem.TryGetProperty("sample", out var sample))
{
if (sample.TryGetProperty("error", out var errObj))
{
result.ErrorCode = errObj.TryGetProperty("code", out var code) ? code.GetString() : null;
result.ErrorMessage = errObj.TryGetProperty("message", out var msg) ? msg.GetString() : null;
}
if (sample.TryGetProperty("usage", out var usage) && usage.TryGetProperty("total_tokens", out var tt) && tt.ValueKind == JsonValueKind.Number)
{
var tokenUsage = new Dictionary<string, int>();
if (usage.TryGetProperty("prompt_tokens", out var pt) && pt.ValueKind == JsonValueKind.Number)
{
tokenUsage["prompt_tokens"] = pt.GetInt32();
}
if (usage.TryGetProperty("completion_tokens", out var ct) && ct.ValueKind == JsonValueKind.Number)
{
tokenUsage["completion_tokens"] = ct.GetInt32();
}
tokenUsage["total_tokens"] = tt.GetInt32();
result.TokenUsage = tokenUsage;
}
// Extract input/output text
if (sample.TryGetProperty("input", out var inputArr) && inputArr.ValueKind == JsonValueKind.Array)
{
var parts = new List<string>();
foreach (var si in inputArr.EnumerateArray())
{
if (si.TryGetProperty("role", out var role) && role.GetString() == "user"
&& si.TryGetProperty("content", out var content))
{
parts.Add(content.GetString() ?? "");
}
}
if (parts.Count > 0)
{
result.InputText = string.Join(" ", parts);
}
}
if (sample.TryGetProperty("output", out var outputArr) && outputArr.ValueKind == JsonValueKind.Array)
{
var parts = new List<string>();
foreach (var so in outputArr.EnumerateArray())
{
if (so.TryGetProperty("role", out var role) && role.GetString() == "assistant"
&& so.TryGetProperty("content", out var content))
{
parts.Add(content.GetString() ?? "");
}
}
if (parts.Count > 0)
{
result.OutputText = string.Join(" ", parts);
}
}
}
// Extract response_id from datasource_item
if (outputItem.TryGetProperty("datasource_item", out var dsItem))
{
if (dsItem.TryGetProperty("resp_id", out var respId))
{
result.ResponseId = respId.GetString();
}
else if (dsItem.TryGetProperty("response_id", out var responseId))
{
result.ResponseId = responseId.GetString();
}
}
return result;
}
internal static string[] FilterToolEvaluators(string[] evaluators, bool hasTools)
{
if (hasTools)
{
return evaluators;
}
var filtered = Array.FindAll(evaluators, e =>
!FoundryEvalConverter.ToolEvaluators.Contains(FoundryEvalConverter.ResolveEvaluator(e)));
return filtered.Length > 0
? filtered
: throw new ArgumentException(
"All configured evaluators require tool definitions, but no tool calls were found in the eval items. "
+ $"Tool evaluators: {string.Join(", ", evaluators)}. Either add tool call content to your EvalItems or remove tool-type evaluators.");
}
}
@@ -112,6 +112,16 @@ public static class FoundryAITool
public static AITool CreateA2ATool(Uri baseUri, string? agentCardPath = null)
=> ProjectsAgentTool.CreateA2ATool(baseUri, agentCardPath).AsAITool();
/// <summary>
/// Creates an <see cref="AITool"/> marker that references a Foundry Toolbox by name so
/// the hosted server side can resolve and expose its MCP tools for a single request.
/// </summary>
/// <param name="toolboxName">The Foundry toolbox name.</param>
/// <param name="version">Optional pinned toolbox version. When <see langword="null"/>, the project's default version is used.</param>
/// <returns>An <see cref="AITool"/> marker backed by <see cref="HostedMcpToolboxAITool"/>.</returns>
public static AITool CreateHostedMcpToolbox(string toolboxName, string? version = null)
=> new HostedMcpToolboxAITool(toolboxName, version);
// --- OpenAI SDK ResponseTool factories ---
/// <summary>
@@ -0,0 +1,156 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// A marker <see cref="HostedMcpServerTool"/> that identifies a Foundry Toolbox by name
/// (and optional version) on the OpenAI Responses <c>mcp</c> wire format.
/// </summary>
/// <remarks>
/// <para>
/// The hosted server recognizes this marker by its <see cref="HostedMcpServerTool.ServerAddress"/>
/// scheme (<see cref="UriScheme"/>) and resolves it to the set of MCP tools exposed by the
/// matching toolbox registered in the Foundry project.
/// </para>
/// <para>
/// Callers should not construct this type directly. Use one of the
/// <c>FoundryAITool.CreateHostedMcpToolbox(...)</c> factory overloads.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class HostedMcpToolboxAITool : HostedMcpServerTool
{
/// <summary>
/// The URI scheme used to identify Foundry Toolbox markers on the wire.
/// </summary>
public const string UriScheme = "foundry-toolbox";
/// <summary>
/// Initializes a new instance of the <see cref="HostedMcpToolboxAITool"/> class.
/// </summary>
/// <param name="toolboxName">The Foundry toolbox name.</param>
/// <param name="version">
/// Optional pinned toolbox version. When <see langword="null"/>, the project's default version is used.
/// Currently reserved for forward compatibility — version-specific routing is handled server-side by
/// the Foundry proxy.
/// </param>
public HostedMcpToolboxAITool(string toolboxName, string? version = null)
: base(
serverName: NotNullOrWhitespace(toolboxName, nameof(toolboxName)),
serverAddress: BuildAddress(toolboxName, version))
{
this.ToolboxName = toolboxName;
this.Version = version;
}
/// <summary>
/// Gets the Foundry toolbox name.
/// </summary>
public string ToolboxName { get; }
/// <summary>
/// Gets the pinned toolbox version, or <see langword="null"/> to use the project's default.
/// </summary>
public string? Version { get; }
/// <summary>
/// Builds the toolbox marker address: <c>foundry-toolbox://{name}[?version={v}]</c>.
/// </summary>
public static string BuildAddress(string toolboxName, string? version)
{
_ = NotNullOrWhitespace(toolboxName, nameof(toolboxName));
return string.IsNullOrEmpty(version)
? $"{UriScheme}://{toolboxName}"
: $"{UriScheme}://{toolboxName}?version={Uri.EscapeDataString(version)}";
}
/// <summary>
/// Attempts to parse a toolbox marker address into its name and optional version components.
/// </summary>
/// <param name="address">The <see cref="HostedMcpServerTool.ServerAddress"/> to inspect.</param>
/// <param name="toolboxName">When this method returns <see langword="true"/>, the parsed toolbox name.</param>
/// <param name="version">When this method returns <see langword="true"/>, the optional version, or <see langword="null"/>.</param>
/// <returns><see langword="true"/> if <paramref name="address"/> is a Foundry toolbox marker; otherwise <see langword="false"/>.</returns>
public static bool TryParseToolboxAddress(
string? address,
[NotNullWhen(true)] out string? toolboxName,
out string? version)
{
toolboxName = null;
version = null;
if (string.IsNullOrEmpty(address))
{
return false;
}
if (!Uri.TryCreate(address, UriKind.Absolute, out var uri))
{
return false;
}
if (!string.Equals(uri.Scheme, UriScheme, StringComparison.OrdinalIgnoreCase))
{
return false;
}
// For foundry-toolbox://name, the name appears as Authority (host) with an empty path.
// For foundry-toolbox:name (rare), it falls through to PathAndQuery.
var name = uri.Host;
if (string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(uri.AbsolutePath))
{
name = uri.AbsolutePath.TrimStart('/');
}
if (string.IsNullOrEmpty(name))
{
return false;
}
toolboxName = name;
var query = uri.Query;
if (!string.IsNullOrEmpty(query))
{
// Minimal parser to avoid a HttpUtility dependency on netstandard.
foreach (var part in query.TrimStart('?').Split('&'))
{
var eq = part.IndexOf('=');
if (eq <= 0)
{
continue;
}
var key = part.Substring(0, eq);
if (string.Equals(key, "version", StringComparison.OrdinalIgnoreCase))
{
version = Uri.UnescapeDataString(part.Substring(eq + 1));
break;
}
}
}
return true;
}
private static string NotNullOrWhitespace(string value, string paramName)
{
if (value is null)
{
throw new ArgumentNullException(paramName);
}
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Value cannot be empty or whitespace.", paramName);
}
return value;
}
}
@@ -23,11 +23,24 @@
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.Extensions.Compliance.Abstractions" />
<PackageReference Include="OpenAI" />
</ItemGroup>
<!-- Evaluation support requires net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<PackageReference Include="Microsoft.Extensions.AI.Evaluation" />
</ItemGroup>
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="Evaluation\**\*.cs" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -4,7 +4,6 @@ using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json.Nodes;
@@ -70,7 +69,14 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
include: null,
cancellationToken).ConfigureAwait(false);
return newItems.AsChatMessages().Single();
ChatMessage[] createdMessages = [.. newItems.AsChatMessages()];
if (createdMessages.Length != 1)
{
throw new InvalidOperationException(
$"Expected exactly one chat message from created conversation item in conversation '{conversationId}', but got {createdMessages.Length}.");
}
return createdMessages[0];
IEnumerable<ResponseItem> GetResponseItems()
{
@@ -208,7 +214,14 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
{
AgentResponseItem responseItem = await this.GetConversationClient().GetProjectConversationItemAsync(conversationId, messageId, include: null, cancellationToken).ConfigureAwait(false);
ResponseItem[] items = [responseItem.AsResponseResultItem()];
return items.AsChatMessages().Single();
ChatMessage[] messages = [.. items.AsChatMessages()];
if (messages.Length != 1)
{
throw new InvalidOperationException(
$"Expected exactly one chat message for message '{messageId}' in conversation '{conversationId}', but got {messages.Length}.");
}
return messages[0];
}
/// <inheritdoc/>
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
namespace Microsoft.Agents.AI.Workflows.Declarative.Kit;
/// <summary>
@@ -25,6 +27,11 @@ public sealed record class ActionExecutorResult
internal static ActionExecutorResult ThrowIfNot(object? message)
{
if (message is PortableValue portableValue && portableValue.IsType(out ActionExecutorResult? unwrapped))
{
return unwrapped;
}
if (message is not ActionExecutorResult executorMessage)
{
throw new DeclarativeActionException($"Unexpected message type: {message?.GetType().Name ?? "(null)"} (Expected: {nameof(ActionExecutorResult)})");
@@ -27,9 +27,11 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
public static string Resume(string id) => $"{id}_{nameof(Resume)}";
}
public static bool RequiresInput(object? message) => message is ExternalInputRequest;
public static bool RequiresInput(object? message) =>
message is ExternalInputRequest || (message is PortableValue pv && pv.IsType(out ExternalInputRequest? _));
public static bool RequiresNothing(object? message) => message is ActionExecutorResult;
public static bool RequiresNothing(object? message) =>
message is ActionExecutorResult || (message is PortableValue pv && pv.IsType(out ActionExecutorResult? _));
private AzureAgentUsage AgentUsage => Throw.IfNull(this.Model.Agent, $"{nameof(this.Model)}.{nameof(this.Model.Agent)}");
private AzureAgentInput? AgentInput => this.Model.Input;
@@ -47,7 +49,11 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
public async ValueTask ResumeAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken)
{
await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false);
ChatMessage? lastMessage = response.Messages.LastOrDefault();
if (lastMessage is not null)
{
await context.SetLastMessageAsync(lastMessage).ConfigureAwait(false);
}
await this.InvokeAgentAsync(context, response.Messages, cancellationToken).ConfigureAwait(false);
}
@@ -83,15 +89,19 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), context).ConfigureAwait(false);
// Attempt to parse the last message as JSON and assign to the response object variable.
try
string? lastMessageText = agentResponse.Messages.LastOrDefault()?.Text;
if (!string.IsNullOrEmpty(lastMessageText))
{
JsonDocument jsonDocument = JsonDocument.Parse(agentResponse.Messages.Last().Text);
Dictionary<string, object?> objectProperties = jsonDocument.ParseRecord(VariableType.RecordType);
await this.AssignAsync(this.AgentOutput?.ResponseObject?.Path, objectProperties.ToFormula(), context).ConfigureAwait(false);
}
catch
{
// Not valid json, skip assignment.
try
{
using JsonDocument jsonDocument = JsonDocument.Parse(lastMessageText);
Dictionary<string, object?> objectProperties = jsonDocument.ParseRecord(VariableType.RecordType);
await this.AssignAsync(this.AgentOutput?.ResponseObject?.Path, objectProperties.ToFormula(), context).ConfigureAwait(false);
}
catch (JsonException)
{
// Not valid json, skip assignment.
}
}
if (this.Model.Input?.ExternalLoop?.When is not null)
@@ -46,12 +46,14 @@ internal sealed class InvokeMcpToolExecutor(
/// <summary>
/// Determines if the message indicates external input is required.
/// </summary>
public static bool RequiresInput(object? message) => message is ExternalInputRequest;
public static bool RequiresInput(object? message) =>
message is ExternalInputRequest || (message is PortableValue pv && pv.IsType(out ExternalInputRequest? _));
/// <summary>
/// Determines if the message indicates no external input is required.
/// </summary>
public static bool RequiresNothing(object? message) => message is ActionExecutorResult;
public static bool RequiresNothing(object? message) =>
message is ActionExecutorResult || (message is PortableValue pv && pv.IsType(out ActionExecutorResult? _));
/// <inheritdoc/>
protected override bool EmitResultEvent => false;
@@ -122,10 +122,13 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
string? workflowConversationId = context.GetWorkflowConversation();
if (workflowConversationId is not null)
{
// Input message always defined if values has been extracted.
ChatMessage input = response.Messages.Last();
await agentProvider.CreateMessageAsync(workflowConversationId, input, cancellationToken).ConfigureAwait(false);
await context.SetLastMessageAsync(input).ConfigureAwait(false);
// Input message expected to be defined when values have been extracted, but guard defensively.
ChatMessage? input = response.Messages.LastOrDefault();
if (input is not null)
{
await agentProvider.CreateMessageAsync(workflowConversationId, input, cancellationToken).ConfigureAwait(false);
await context.SetLastMessageAsync(input).ConfigureAwait(false);
}
}
}
@@ -45,7 +45,11 @@ internal sealed class RequestExternalInputExecutor(RequestExternalInput model, R
await agentProvider.CreateMessageAsync(workflowConversationId, inputMessage, cancellationToken).ConfigureAwait(false);
}
}
await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false);
ChatMessage? lastMessage = response.Messages.LastOrDefault();
if (lastMessage is not null)
{
await context.SetLastMessageAsync(lastMessage).ConfigureAwait(false);
}
await this.AssignAsync(this.Model.Variable?.Path, response.Messages.ToFormula(), context).ConfigureAwait(false);
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
@@ -254,7 +254,7 @@ internal static class SemanticAnalyzer
/// <summary>
/// Combines ClassProtocolInfo results into an AnalysisResult for classes that only have IO attributes
/// (no [MessageHandler] methods). This generates only .SendsMessage/.YieldsMessage calls in the protocol
/// (no [MessageHandler] methods). This generates only .SendsMessage/.YieldsOutput calls in the protocol
/// configuration.
/// </summary>
/// <remarks>
@@ -9,17 +9,6 @@ namespace Microsoft.Agents.AI.Workflows;
internal static class AIAgentsAbstractionsExtensions
{
public static ChatMessage ToChatMessage(this AgentResponseUpdate update) =>
new()
{
AuthorName = update.AuthorName,
Contents = update.Contents,
Role = update.Role ?? ChatRole.User,
CreatedAt = update.CreatedAt,
MessageId = update.MessageId,
RawRepresentation = update.RawRepresentation ?? update,
};
public static ChatMessage ChatAssistantToUserIfNotFromNamed(this ChatMessage message, string agentName)
=> message.ChatAssistantToUserIfNotFromNamed(agentName, out _, false);
@@ -48,7 +37,7 @@ internal static class AIAgentsAbstractionsExtensions
/// any that have a different <see cref="ChatMessage.AuthorName"/> from <paramref name="targetAgentName"/> to
/// <see cref="ChatRole.User"/>.
/// </summary>
public static List<ChatMessage>? ChangeAssistantToUserForOtherParticipants(this List<ChatMessage> messages, string targetAgentName)
public static List<ChatMessage>? ChangeAssistantToUserForOtherParticipants(this IEnumerable<ChatMessage> messages, string targetAgentName)
{
List<ChatMessage>? roleChanged = null;
foreach (var m in messages)
@@ -47,7 +47,7 @@ public sealed class ChatForwardingExecutor(string id, ChatForwardingExecutorOpti
if (this._stringMessageChatRole.HasValue)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message)));
(message, context) => context.SendMessageAsync(new ChatMessage(this._stringMessageChatRole.Value, message)));
}
routeBuilder.AddHandler<ChatMessage>(ForwardMessageAsync)
@@ -0,0 +1,175 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Evaluation;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Extension methods for evaluating workflow runs.
/// </summary>
public static class WorkflowEvaluationExtensions
{
/// <summary>
/// Evaluates a completed workflow run.
/// </summary>
/// <param name="run">The completed workflow run.</param>
/// <param name="evaluator">The evaluator to score results.</param>
/// <param name="includeOverall">Whether to include an overall evaluation.</param>
/// <param name="includePerAgent">Whether to include per-agent breakdowns.</param>
/// <param name="evalName">Display name for this evaluation run.</param>
/// <param name="splitter">
/// Optional conversation splitter to apply to all items.
/// Use <see cref="ConversationSplitters.LastTurn"/>, <see cref="ConversationSplitters.Full"/>,
/// or a custom <see cref="IConversationSplitter"/> implementation.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Evaluation results with optional per-agent sub-results.</returns>
public static async Task<AgentEvaluationResults> EvaluateAsync(
this Run run,
IAgentEvaluator evaluator,
bool includeOverall = true,
bool includePerAgent = true,
string evalName = "Workflow Eval",
IConversationSplitter? splitter = null,
CancellationToken cancellationToken = default)
{
var events = run.OutgoingEvents.ToList();
// Extract per-agent data
var agentData = ExtractAgentData(events, splitter);
// Build overall items from final output
var overallItems = new List<EvalItem>();
if (includeOverall)
{
var finalResponse = events.OfType<AgentResponseEvent>().LastOrDefault();
if (finalResponse is not null)
{
var firstInvoked = events.OfType<ExecutorInvokedEvent>().FirstOrDefault();
var query = firstInvoked?.Data switch
{
ChatMessage cm => cm.Text ?? string.Empty,
IReadOnlyList<ChatMessage> msgs => msgs.LastOrDefault(m => m.Role == ChatRole.User)?.Text ?? string.Empty,
string s => s,
_ => firstInvoked?.Data?.ToString() ?? string.Empty,
};
var conversation = new List<ChatMessage>
{
new(ChatRole.User, query),
};
conversation.AddRange(finalResponse.Response.Messages);
overallItems.Add(new EvalItem(query, finalResponse.Response.Text, conversation)
{
Splitter = splitter,
});
}
}
// Evaluate overall
var overallResult = overallItems.Count > 0
? await evaluator.EvaluateAsync(overallItems, evalName, cancellationToken).ConfigureAwait(false)
: new AgentEvaluationResults(evaluator.Name, Array.Empty<EvaluationResult>());
// Per-agent breakdown
if (includePerAgent && agentData.Count > 0)
{
var subResults = new Dictionary<string, AgentEvaluationResults>();
foreach (var kvp in agentData)
{
subResults[kvp.Key] = await evaluator.EvaluateAsync(
kvp.Value,
$"{evalName} - {kvp.Key}",
cancellationToken).ConfigureAwait(false);
}
overallResult.SubResults = subResults;
}
return overallResult;
}
internal static Dictionary<string, List<EvalItem>> ExtractAgentData(
List<WorkflowEvent> events,
IConversationSplitter? splitter)
{
var invoked = new Dictionary<string, ExecutorInvokedEvent>();
var agentData = new Dictionary<string, List<EvalItem>>();
foreach (var evt in events)
{
if (evt is ExecutorInvokedEvent invokedEvent)
{
if (IsInternalExecutor(invokedEvent.ExecutorId))
{
continue;
}
invoked[invokedEvent.ExecutorId] = invokedEvent;
}
else if (evt is ExecutorCompletedEvent completedEvent
&& invoked.TryGetValue(completedEvent.ExecutorId, out var matchingInvoked))
{
var query = matchingInvoked.Data switch
{
ChatMessage cm => cm.Text ?? string.Empty,
IReadOnlyList<ChatMessage> msgs => msgs.LastOrDefault(m => m.Role == ChatRole.User)?.Text ?? string.Empty,
string s => s,
_ => matchingInvoked.Data?.ToString() ?? string.Empty,
};
var responseText = completedEvent.Data switch
{
AgentResponse ar => ar.Text,
ChatMessage cm => cm.Text ?? string.Empty,
string s => s,
_ => completedEvent.Data?.ToString() ?? string.Empty,
};
var agentResponse = completedEvent.Data as AgentResponse;
var conversation = new List<ChatMessage>
{
new(ChatRole.User, query),
};
if (agentResponse is not null)
{
conversation.AddRange(agentResponse.Messages);
}
else
{
conversation.Add(new(ChatRole.Assistant, responseText));
}
var item = new EvalItem(query, responseText, conversation)
{
Splitter = splitter,
};
if (!agentData.TryGetValue(completedEvent.ExecutorId, out var items))
{
items = new List<EvalItem>();
agentData[completedEvent.ExecutorId] = items;
}
items.Add(item);
invoked.Remove(completedEvent.ExecutorId);
}
}
return agentData;
}
private static bool IsInternalExecutor(string executorId)
{
return executorId.StartsWith('_')
|| executorId is "input-conversation" or "end-conversation" or "end";
}
}
@@ -73,7 +73,14 @@ public class FunctionExecutor<TInput>(string id,
ExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false) : this(id, WrapAction(handlerSync, out var attributeSentTypes, out var attributeYieldTypes), options, attributeSentTypes.Concat(sentMessageTypes ?? []), attributeYieldTypes.Concat(outputTypes ?? []), declareCrossRunShareable)
bool declareCrossRunShareable = false) : this(id,
WrapAction(handlerSync,
out var attributeSentTypes,
out var attributeYieldTypes),
options,
attributeSentTypes.Concat(sentMessageTypes ?? []),
attributeYieldTypes.Concat(outputTypes ?? []),
declareCrossRunShareable)
{
}
}
@@ -96,8 +103,18 @@ public class FunctionExecutor<TInput, TOutput>(string id,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false) : Executor<TInput, TOutput>(id, options, declareCrossRunShareable)
{
internal static Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> WrapFunc(Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync)
internal static Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> WrapFunc(Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync, out IEnumerable<Type> sentTypes, out IEnumerable<Type> yieldedTypes)
{
if (handlerSync.Method != null)
{
MethodInfo method = handlerSync.Method;
(sentTypes, yieldedTypes) = method.GetAttributeTypes();
}
else
{
sentTypes = yieldedTypes = [];
}
return RunFuncAsync;
ValueTask<TOutput> RunFuncAsync(TInput input, IWorkflowContext workflowContext, CancellationToken cancellationToken)
@@ -133,7 +150,14 @@ public class FunctionExecutor<TInput, TOutput>(string id,
ExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false) : this(id, WrapFunc(handlerSync), options, sentMessageTypes, outputTypes, declareCrossRunShareable)
bool declareCrossRunShareable = false) : this(id,
WrapFunc(handlerSync,
out var attributeSentTypes,
out var attributeYieldTypes),
options,
attributeSentTypes.Concat(sentMessageTypes ?? []),
attributeYieldTypes.Concat(outputTypes ?? []),
declareCrossRunShareable)
{
}
}
@@ -20,6 +20,7 @@ internal static class DiagnosticConstants
}
/// <inheritdoc/>
[ExcludeFromCodeCoverage] // This is obsolete, and 1:1 equivalent to HandoffWorkflowBuilder (no "s")
[Obsolete("Prefer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed in a future release before GA.")]
#pragma warning disable MAAIW001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore<HandoffsWorkflowBuilder>(initialAgent)
@@ -219,13 +220,17 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
if (string.IsNullOrWhiteSpace(handoffReason))
{
handoffReason = to.Description ?? to.Name ?? (to as ChatClientAgent)?.Instructions;
handoffReason = (string.IsNullOrWhiteSpace(to.Description) ? null : to.Description)
?? (string.IsNullOrWhiteSpace(to.Name) ? null : $"handoff to {to.Name}")
?? to.GetService<ChatClientAgent>()?.Instructions;
if (string.IsNullOrWhiteSpace(handoffReason))
{
Throw.ArgumentException(
nameof(to),
$"The provided target agent '{to.Name ?? to.Id}' has no description, name, or instructions, and no handoff description has been provided. " +
"At least one of these is required to register a handoff so that the appropriate target agent can be chosen.");
$"The provided target agent '{(string.IsNullOrWhiteSpace(to.Name) ? to.Id : to.Name)}' has no description, name, or instructions, and no " +
"handoff description has been provided. At least one of these is required to register a handoff so that the appropriate target agent can " +
"be chosen.");
}
}
@@ -55,4 +55,9 @@
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
<!-- Evaluation support requires net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="Evaluation\**\*.cs" />
</ItemGroup>
</Project>
@@ -29,6 +29,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// }
/// </code>
/// </example>
[Obsolete("Use YieldsOutput instead. The Code Generator and the runtime attribute-based type mapping ignore this attribute.")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class YieldsMessageAttribute : Attribute
{
@@ -47,3 +48,25 @@ public sealed class YieldsMessageAttribute : Attribute
this.Type = Throw.IfNull(type);
}
}
/// <summary>
/// This attribute indicates that a message handler streams messages during its execution.
/// </summary>
[Obsolete("This attribute does not do anything. The Code Generator and the runtime attribute-based type mapping ignore this attribute.")]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public sealed class StreamsMessageAttribute : Attribute
{
/// <summary>
/// The type of the message that the handler yields.
/// </summary>
public Type Type { get; }
/// <summary>
/// Indicates that the message handler yields streaming messages during the course of execution.
/// </summary>
public StreamsMessageAttribute(Type type)
{
// This attribute is used to mark executors that yield messages.
this.Type = Throw.IfNull(type);
}
}
@@ -6,6 +6,7 @@ using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -31,140 +32,6 @@ internal sealed class HandoffAgentExecutorOptions
public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly;
}
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
internal sealed class HandoffMessagesFilter
{
private readonly HandoffToolCallFilteringBehavior _filteringBehavior;
public HandoffMessagesFilter(HandoffToolCallFilteringBehavior filteringBehavior)
{
this._filteringBehavior = filteringBehavior;
}
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
internal static bool IsHandoffFunctionName(string name)
{
return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
}
public IEnumerable<ChatMessage> FilterMessages(List<ChatMessage> messages)
{
if (this._filteringBehavior == HandoffToolCallFilteringBehavior.None)
{
return messages;
}
Dictionary<string, FilterCandidateState> filteringCandidates = new();
List<ChatMessage> filteredMessages = [];
HashSet<int> messagesToRemove = [];
bool filterHandoffOnly = this._filteringBehavior == HandoffToolCallFilteringBehavior.HandoffOnly;
foreach (ChatMessage unfilteredMessage in messages)
{
ChatMessage filteredMessage = unfilteredMessage.Clone();
// .Clone() is shallow, so we cannot modify the contents of the cloned message in place.
List<AIContent> contents = [];
contents.Capacity = unfilteredMessage.Contents?.Count ?? 0;
filteredMessage.Contents = contents;
// Because this runs after the role changes from assistant to user for the target agent, we cannot rely on tool calls
// originating only from messages with the Assistant role. Instead, we need to inspect the contents of all non-Tool (result)
// FunctionCallContent.
if (unfilteredMessage.Role != ChatRole.Tool)
{
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
{
AIContent content = unfilteredMessage.Contents[i];
if (content is not FunctionCallContent fcc || (filterHandoffOnly && !IsHandoffFunctionName(fcc.Name)))
{
filteredMessage.Contents.Add(content);
// Track non-handoff function calls so their tool results are preserved in HandoffOnly mode
if (filterHandoffOnly && content is FunctionCallContent nonHandoffFcc)
{
filteringCandidates[nonHandoffFcc.CallId] = new FilterCandidateState(nonHandoffFcc.CallId)
{
IsHandoffFunction = false,
};
}
}
else if (filterHandoffOnly)
{
if (!filteringCandidates.TryGetValue(fcc.CallId, out FilterCandidateState? candidateState))
{
filteringCandidates[fcc.CallId] = new FilterCandidateState(fcc.CallId)
{
IsHandoffFunction = true,
};
}
else
{
candidateState.IsHandoffFunction = true;
(int messageIndex, int contentIndex) = candidateState.FunctionCallResultLocation!.Value;
ChatMessage messageToFilter = filteredMessages[messageIndex];
messageToFilter.Contents.RemoveAt(contentIndex);
if (messageToFilter.Contents.Count == 0)
{
messagesToRemove.Add(messageIndex);
}
}
}
else
{
// All mode: strip all FunctionCallContent
}
}
}
else
{
if (!filterHandoffOnly)
{
continue;
}
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
{
AIContent content = unfilteredMessage.Contents[i];
if (content is not FunctionResultContent frc
|| (filteringCandidates.TryGetValue(frc.CallId, out FilterCandidateState? candidateState)
&& candidateState.IsHandoffFunction is false))
{
// Either this is not a function result content, so we should let it through, or it is a FRC that
// we know is not related to a handoff call. In either case, we should include it.
filteredMessage.Contents.Add(content);
}
else if (candidateState is null)
{
// We haven't seen the corresponding function call yet, so add it as a candidate to be filtered later
filteringCandidates[frc.CallId] = new FilterCandidateState(frc.CallId)
{
FunctionCallResultLocation = (filteredMessages.Count, filteredMessage.Contents.Count),
};
}
// else we have seen the corresponding function call and it is a handoff, so we should filter it out.
}
}
if (filteredMessage.Contents.Count > 0)
{
filteredMessages.Add(filteredMessage);
}
}
return filteredMessages.Where((_, index) => !messagesToRemove.Contains(index));
}
private class FilterCandidateState(string callId)
{
public (int MessageIndex, int ContentIndex)? FunctionCallResultLocation { get; set; }
public string CallId => callId;
public bool? IsHandoffFunction { get; set; }
}
}
internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId)
{
public AgentResponse Response => agentResponse;
@@ -175,19 +42,31 @@ internal struct AgentInvocationResult(AgentResponse agentResponse, string? hando
public bool IsHandoffRequested => this.HandoffTargetId != null;
}
internal record HandoffAgentHostState(HandoffState? CurrentTurnState, List<ChatMessage> FilteredIncomingMessages, List<ChatMessage> TurnMessages)
internal record HandoffAgentHostState(
HandoffState? IncomingState,
int ConversationBookmark)
{
public HandoffState PrepareHandoff(AgentInvocationResult invocationResult, string currentAgentId)
{
if (this.CurrentTurnState == null)
{
throw new InvalidOperationException("Cannot create a handoff request: Out of turn.");
}
[MemberNotNullWhen(true, nameof(IncomingState))]
[JsonIgnore]
public bool IsTakingTurn => this.IncomingState != null;
}
IEnumerable<ChatMessage> allMessages = [.. this.CurrentTurnState.Messages, .. this.TurnMessages, .. invocationResult.Response.Messages];
internal sealed record StateRef<TState>(string Key, string? ScopeName)
{
public ValueTask InvokeWithStateAsync(Func<TState?, IWorkflowContext, CancellationToken, ValueTask<TState?>> invocation,
IWorkflowContext context,
CancellationToken cancellationToken)
=> context.InvokeWithStateAsync(invocation, this.Key, this.ScopeName, cancellationToken);
return new(this.CurrentTurnState.TurnToken, invocationResult.HandoffTargetId, allMessages.ToList(), currentAgentId);
}
public ValueTask InvokeWithStateAsync(Func<TState?, IWorkflowContext, CancellationToken, ValueTask> invocation,
IWorkflowContext context,
CancellationToken cancellationToken)
=> context.InvokeWithStateAsync<TState>(
async (state, ctx, ct) =>
{
await invocation(state, ctx, ct).ConfigureAwait(false);
return state;
}, this.Key, this.ScopeName, cancellationToken);
}
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
@@ -208,7 +87,13 @@ internal sealed class HandoffAgentExecutor :
private readonly HashSet<string> _handoffFunctionNames = [];
private readonly Dictionary<string, string> _handoffFunctionToAgentId = [];
private static HandoffAgentHostState InitialStateFactory() => new(null, [], []);
private readonly StateRef<HandoffSharedState> _sharedStateRef = new(HandoffConstants.HandoffSharedStateKey,
HandoffConstants.HandoffSharedStateScope);
internal const string AgentSessionKey = nameof(AgentSession);
private AgentSession? _session;
private static HandoffAgentHostState InitialStateFactory() => new(null, 0);
public HandoffAgentExecutor(AIAgent agent, HashSet<HandoffTarget> handoffs, HandoffAgentExecutorOptions options)
: base(IdFor(agent), InitialStateFactory)
@@ -291,13 +176,18 @@ internal sealed class HandoffAgentExecutor :
// resumes can be processed in one invocation.
return this.InvokeWithStateAsync((state, ctx, ct) =>
{
state.TurnMessages.Add(new ChatMessage(ChatRole.User, [response])
if (!state.IsTakingTurn)
{
throw new InvalidOperationException("Cannot process user responses when not taking a turn in Handoff Orchestration.");
}
ChatMessage userMessage = new(ChatRole.User, [response])
{
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
});
};
return this.ContinueTurnAsync(state, ctx, ct);
return this.ContinueTurnAsync(state, [userMessage], ctx, ct);
}, context, skipCache: false, cancellationToken);
}
@@ -315,24 +205,44 @@ internal sealed class HandoffAgentExecutor :
// resumes can be processed in one invocation.
return this.InvokeWithStateAsync((state, ctx, ct) =>
{
state.TurnMessages.Add(
new ChatMessage(ChatRole.Tool, [result])
{
AuthorName = this._agent.Name ?? this._agent.Id,
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
});
if (!state.IsTakingTurn)
{
throw new InvalidOperationException("Cannot process user responses in when not taking a turn in Handoff Orchestration.");
}
return this.ContinueTurnAsync(state, ctx, ct);
ChatMessage toolMessage = new(ChatRole.Tool, [result])
{
AuthorName = this._agent.Name ?? this._agent.Id,
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
};
return this.ContinueTurnAsync(state, [toolMessage], ctx, ct);
}, context, skipCache: false, cancellationToken);
}
private async ValueTask<HandoffAgentHostState?> ContinueTurnAsync(HandoffAgentHostState state, IWorkflowContext context, CancellationToken cancellationToken)
private async ValueTask<HandoffAgentHostState?> ContinueTurnAsync(HandoffAgentHostState state, List<ChatMessage> incomingMessages, IWorkflowContext context, CancellationToken cancellationToken, bool skipAddIncoming = false)
{
List<ChatMessage>? roleChanges = state.FilteredIncomingMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
if (!state.IsTakingTurn)
{
throw new InvalidOperationException("Cannot process user responses in when not taking a turn in Handoff Orchestration.");
}
bool emitUpdateEvents = state.CurrentTurnState!.ShouldEmitStreamingEvents(this._options.EmitAgentResponseUpdateEvents);
AgentInvocationResult result = await this.InvokeAgentAsync([.. state.FilteredIncomingMessages, .. state.TurnMessages], context, emitUpdateEvents, cancellationToken)
// If a handoff was invoked by a previous agent, filter out the handoff function call and tool result messages
// before sending to the underlying agent. These are internal workflow mechanics that confuse the target model
// into ignoring the original user question.
//
// This will not filter out tool responses and approval responses that are part of this agent's turn, which is
// the expected behavior since those are part of the agent's reasoning process.
HandoffMessagesFilter handoffMessagesFilter = new(this._options.ToolCallFilteringBehavior);
IEnumerable<ChatMessage> messagesForAgent = state.IncomingState.RequestedHandoffTargetAgentId is not null
? handoffMessagesFilter.FilterMessages(incomingMessages)
: incomingMessages;
List<ChatMessage>? roleChanges = messagesForAgent.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
bool emitUpdateEvents = state.IncomingState!.ShouldEmitStreamingEvents(this._options.EmitAgentResponseUpdateEvents);
AgentInvocationResult result = await this.InvokeAgentAsync(messagesForAgent, context, emitUpdateEvents, cancellationToken)
.ConfigureAwait(false);
if (this.HasOutstandingRequests && result.IsHandoffRequested)
@@ -342,20 +252,40 @@ internal sealed class HandoffAgentExecutor :
roleChanges.ResetUserToAssistantForChangedRoles();
int newConversationBookmark = state.ConversationBookmark;
await this._sharedStateRef.InvokeWithStateAsync(
(sharedState, ctx, ct) =>
{
if (sharedState == null)
{
throw new InvalidOperationException("Handoff Orchestration shared state was not properly initialized.");
}
if (!skipAddIncoming)
{
sharedState.Conversation.AddMessages(incomingMessages);
}
newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages);
return new ValueTask();
},
context,
cancellationToken).ConfigureAwait(false);
// We send on the HandoffState even if handoff is not requested because we might be terminating the processing, but this only
// happens if we have no outstanding requests.
if (!this.HasOutstandingRequests)
{
HandoffState outgoingState = state.PrepareHandoff(result, this._agent.Id);
HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id);
await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false);
// reset the state for the next handoff (return-to-current is modeled as a new handoff turn, as opposed to "HITL", which
// can be a bit confusing.)
return null;
// reset the state for the next handoff, making sure to keep track of the conversation bookmark, and avoid resetting the
// agent session. (return-to-current is modeled as a new handoff turn, as opposed to "HITL", which can be a bit confusing.)
return state with { IncomingState = null, ConversationBookmark = newConversationBookmark };
}
state.TurnMessages.AddRange(result.Response.Messages);
return state;
}
@@ -363,28 +293,36 @@ internal sealed class HandoffAgentExecutor :
{
return this.InvokeWithStateAsync(InvokeContinueTurnAsync, context, skipCache: false, cancellationToken);
ValueTask<HandoffAgentHostState?> InvokeContinueTurnAsync(HandoffAgentHostState state, IWorkflowContext context, CancellationToken cancellationToken)
async ValueTask<HandoffAgentHostState?> InvokeContinueTurnAsync(HandoffAgentHostState state, IWorkflowContext context, CancellationToken cancellationToken)
{
// Check that we are not getting this message while in the middle of a turn
if (state.CurrentTurnState != null)
if (state.IsTakingTurn)
{
throw new InvalidOperationException("Cannot have multiple simultaneous conversations in Handoff Orchestration.");
}
// If a handoff was invoked by a previous agent, filter out the handoff function
// call and tool result messages before sending to the underlying agent. These
// are internal workflow mechanics that confuse the target model into ignoring the
// original user question.
HandoffMessagesFilter handoffMessagesFilter = new(this._options.ToolCallFilteringBehavior);
IEnumerable<ChatMessage> messagesForAgent = message.RequestedHandoffTargetAgentId is not null
? handoffMessagesFilter.FilterMessages(message.Messages)
: message.Messages;
IEnumerable<ChatMessage> newConversationMessages = [];
int newConversationBookmark = 0;
// This works because the runtime guarantees that a given executor instance will process messages serially,
// though there is no global cross-executor ordering guarantee (and in turn, no canonical message delivery order)
state = new(message, messagesForAgent.ToList(), []);
await this._sharedStateRef.InvokeWithStateAsync(
(sharedState, ctx, ct) =>
{
if (sharedState == null)
{
throw new InvalidOperationException("Handoff Orchestration shared state was not properly initialized.");
}
return this.ContinueTurnAsync(state, context, cancellationToken);
(newConversationMessages, newConversationBookmark) = sharedState.Conversation.CollectNewMessages(state.ConversationBookmark);
return new ValueTask();
},
context,
cancellationToken).ConfigureAwait(false);
state = state with { IncomingState = message, ConversationBookmark = newConversationBookmark };
return await this.ContinueTurnAsync(state, newConversationMessages.ToList(), context, cancellationToken, skipAddIncoming: true)
.ConfigureAwait(false);
}
}
@@ -395,18 +333,35 @@ internal sealed class HandoffAgentExecutor :
{
Task userInputRequestsTask = this._userInputHandler?.OnCheckpointingAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task functionCallRequestsTask = this._functionCallHandler?.OnCheckpointingAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task agentSessionTask = CheckpointAgentSessionAsync();
Task baseTask = base.OnCheckpointingAsync(context, cancellationToken).AsTask();
await Task.WhenAll(userInputRequestsTask, functionCallRequestsTask, baseTask).ConfigureAwait(false);
await Task.WhenAll(userInputRequestsTask, functionCallRequestsTask, agentSessionTask, baseTask).ConfigureAwait(false);
async Task CheckpointAgentSessionAsync()
{
JsonElement? sessionState = this._session is not null ? await this._agent.SerializeSessionAsync(this._session, cancellationToken: cancellationToken).ConfigureAwait(false) : null;
await context.QueueStateUpdateAsync(AgentSessionKey, sessionState, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
Task userInputRestoreTask = this._userInputHandler?.OnCheckpointRestoredAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task functionCallRestoreTask = this._functionCallHandler?.OnCheckpointRestoredAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task agentSessionTask = RestoreAgentSessionAsync();
await Task.WhenAll(userInputRestoreTask, functionCallRestoreTask).ConfigureAwait(false);
await Task.WhenAll(userInputRestoreTask, functionCallRestoreTask, agentSessionTask).ConfigureAwait(false);
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
async Task RestoreAgentSessionAsync()
{
JsonElement? sessionState = await context.ReadStateAsync<JsonElement?>(AgentSessionKey, cancellationToken: cancellationToken).ConfigureAwait(false);
if (sessionState.HasValue)
{
this._session = await this._agent.DeserializeSessionAsync(sessionState.Value, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}
private bool HasOutstandingRequests => (this._userInputHandler?.HasPendingRequests == true)
|| (this._functionCallHandler?.HasPendingRequests == true);
@@ -417,31 +372,43 @@ internal sealed class HandoffAgentExecutor :
AIAgentUnservicedRequestsCollector collector = new(this._userInputHandler, this._functionCallHandler);
IAsyncEnumerable<AgentResponseUpdate> agentStream = this._agent.RunStreamingAsync(
messages,
options: this._agentOptions,
cancellationToken: cancellationToken);
string? requestedHandoff = null;
List<AgentResponseUpdate> updates = [];
List<FunctionCallContent> candidateRequests = [];
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
{
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter);
bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest)
await this.InvokeWithStateAsync(
async (state, ctx, ct) =>
{
bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name);
if (isHandoffRequest)
this._session ??= await this._agent.CreateSessionAsync(ct).ConfigureAwait(false);
IAsyncEnumerable<AgentResponseUpdate> agentStream =
this._agent.RunStreamingAsync(messages,
this._session,
options: this._agentOptions,
cancellationToken: ct);
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
{
candidateRequests.Add(candidateHandoffRequest);
await AddUpdateAsync(update, ct).ConfigureAwait(false);
collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter);
bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest)
{
bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name);
if (isHandoffRequest)
{
candidateRequests.Add(candidateHandoffRequest);
}
return !isHandoffRequest;
}
}
return !isHandoffRequest;
}
}
return state;
},
context,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (candidateRequests.Count > 1)
{
@@ -459,7 +426,7 @@ internal sealed class HandoffAgentExecutor :
{
AgentId = this._agent.Id,
AuthorName = this._agent.Name ?? this._agent.Id,
Contents = [new FunctionResultContent(handoffRequest.CallId, "Transferred.")],
Contents = [CreateHandoffResult(handoffRequest.CallId)],
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Tool,
@@ -492,4 +459,6 @@ internal sealed class HandoffAgentExecutor :
? this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetId) ? targetId : null
: null;
}
internal static FunctionResultContent CreateHandoffResult(string requestCallId) => new(requestCallId, "Transferred.");
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -12,23 +13,33 @@ internal sealed class HandoffEndExecutor(bool returnToPrevious) : Executor(Execu
{
public const string ExecutorId = "HandoffEnd";
private readonly StateRef<HandoffSharedState> _sharedStateRef = new(HandoffConstants.HandoffSharedStateKey,
HandoffConstants.HandoffSharedStateScope);
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<HandoffState>((handoff, context, cancellationToken) =>
this.HandleAsync(handoff, context, cancellationToken)))
protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<HandoffState>(
(handoff, context, cancellationToken) => this.HandleAsync(handoff, context, cancellationToken)))
.YieldsOutput<List<ChatMessage>>();
private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken)
{
if (returnToPrevious)
{
await context.QueueStateUpdateAsync<string?>(HandoffConstants.PreviousAgentTrackerKey,
handoff.PreviousAgentId,
HandoffConstants.PreviousAgentTrackerScope,
cancellationToken)
.ConfigureAwait(false);
}
await this._sharedStateRef.InvokeWithStateAsync(
async (HandoffSharedState? sharedState, IWorkflowContext context, CancellationToken cancellationToken) =>
{
if (sharedState == null)
{
throw new InvalidOperationException("Handoff Orchestration shared state was not properly initialized.");
}
await context.YieldOutputAsync(handoff.Messages, cancellationToken).ConfigureAwait(false);
if (returnToPrevious)
{
sharedState.PreviousAgentId = handoff.PreviousAgentId;
}
await context.YieldOutputAsync(sharedState.Conversation.CloneAllMessages(), cancellationToken).ConfigureAwait(false);
return sharedState;
}, context, cancellationToken).ConfigureAwait(false);
}
public ValueTask ResetAsync() => default;
@@ -0,0 +1,107 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
internal sealed class HandoffMessagesFilter
{
private readonly HandoffToolCallFilteringBehavior _filteringBehavior;
public HandoffMessagesFilter(HandoffToolCallFilteringBehavior filteringBehavior)
{
this._filteringBehavior = filteringBehavior;
}
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
internal static bool IsHandoffFunctionName(string name)
{
return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
}
public IEnumerable<ChatMessage> FilterMessages(IEnumerable<ChatMessage> messages)
{
if (this._filteringBehavior == HandoffToolCallFilteringBehavior.None)
{
return messages;
}
HashSet<string> filteredCallsWithoutResponses = new();
List<ChatMessage> retainedMessages = [];
bool filterAllToolCalls = this._filteringBehavior == HandoffToolCallFilteringBehavior.All;
// The logic of filtering is fairly straightforward: We are only interested in FunctionCallContent and FunctionResponseContent.
// We are going to assume that Handoff operates as follows:
// * Each agent is only taking one turn at a time
// * Each agent is taking a turn alone
//
// In the case of certain providers, like Gemini (see microsoft/agent-framework #5244), we will see the function call name as the
// call id as well, so we may see multiple calls with the same call id, and assume that the call is terminated before another
// "CallId-less" FCC is issued. We also need to rely on the idea that FRC follows their corresponding FCC in the message stream.
// (This changes the previous behaviour where FRC could arrive earlier, and relies on strict ordering).
//
// The benefit of expecting all the AIContent to be strictly ordered is that we never need to reach back into a post-filtered
// content to retroactively remove it, or to try to inject it back into the middle of a Message that has already been processed.
foreach (ChatMessage unfilteredMessage in messages)
{
if (unfilteredMessage.Contents is null || unfilteredMessage.Contents.Count == 0)
{
retainedMessages.Add(unfilteredMessage);
continue;
}
// We may need to filter out a subset of the message's content, but we won't know until we iterate through it. Create a new list
// of AIContent which we will stuff into a clone of the message if we need to filter out any content.
List<AIContent> retainedContents = new(capacity: unfilteredMessage.Contents.Count);
foreach (AIContent content in unfilteredMessage.Contents)
{
if (content is FunctionCallContent fcc
&& (filterAllToolCalls || IsHandoffFunctionName(fcc.Name)))
{
// If we already have an unmatched candidate with the same CallId, that means we have two FCCs in a row without an FRC,
// which violates our assumption of strict ordering.
if (!filteredCallsWithoutResponses.Add(fcc.CallId))
{
throw new InvalidOperationException($"Duplicate FunctionCallContent with CallId '{fcc.CallId}' without corresponding FunctionResultContent.");
}
// If we are filtering all tool calls, or this is a handoff call (and we are not filtering None, already checked), then
// filter this FCC
continue;
}
else if (content is FunctionResultContent frc)
{
// We rely on the corresponding FCC to have already been processed, so check if it is in the candidate dictionary.
// If it is, we can filter out the FRC, but we need to remove the candidate from the dictionary, since a future FCC can
// come in with the same CallId, and should be considered a new call that may need to be filtered.
if (filteredCallsWithoutResponses.Remove(frc.CallId))
{
continue;
}
}
// FCC/FRC, but not filtered, or neither FCC nor FRC: this should not be filtered out
retainedContents.Add(content);
}
if (retainedContents.Count == 0)
{
// message was fully filtered, skip it
continue;
}
ChatMessage filteredMessage = unfilteredMessage.Clone();
filteredMessage.Contents = retainedContents;
retainedMessages.Add(filteredMessage);
}
return retainedMessages;
}
}
@@ -9,8 +9,23 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal static class HandoffConstants
{
internal const string HandoffOrchestrationSharedScope = "HandoffOrchestration";
internal const string PreviousAgentTrackerKey = "LastAgentId";
internal const string PreviousAgentTrackerScope = "HandoffOrchestration";
internal const string PreviousAgentTrackerScope = HandoffOrchestrationSharedScope;
internal const string MultiPartyConversationKey = "MultiPartyConversation";
internal const string MultiPartyConversationScope = HandoffOrchestrationSharedScope;
internal const string HandoffSharedStateKey = "SharedState";
internal const string HandoffSharedStateScope = HandoffOrchestrationSharedScope;
}
internal sealed class HandoffSharedState
{
public MultiPartyConversation Conversation { get; } = new();
public string? PreviousAgentId { get; set; }
}
/// <summary>Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.</summary>
@@ -29,23 +44,25 @@ internal sealed class HandoffStartExecutor(bool returnToPrevious) : ChatProtocol
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
{
if (returnToPrevious)
{
return context.InvokeWithStateAsync(
async (string? previousAgentId, IWorkflowContext context, CancellationToken cancellationToken) =>
{
HandoffState handoffState = new(new(emitEvents), null, messages, previousAgentId);
await context.SendMessageAsync(handoffState, cancellationToken).ConfigureAwait(false);
return context.InvokeWithStateAsync(
async (HandoffSharedState? sharedState, IWorkflowContext context, CancellationToken cancellationToken) =>
{
sharedState ??= new HandoffSharedState();
sharedState.Conversation.AddMessages(messages);
return previousAgentId;
},
HandoffConstants.PreviousAgentTrackerKey,
HandoffConstants.PreviousAgentTrackerScope,
cancellationToken);
}
string? previousAgentId = sharedState.PreviousAgentId;
HandoffState handoff = new(new(emitEvents), null, messages);
return context.SendMessageAsync(handoff, cancellationToken);
// If we are configured to return to the previous agent, include the previous agent id in the handoff state.
// If there was no previousAgent, it will still be null.
HandoffState turnState = new(new(emitEvents), null, returnToPrevious ? previousAgentId : null);
await context.SendMessageAsync(turnState, cancellationToken).ConfigureAwait(false);
return sharedState;
},
HandoffConstants.HandoffSharedStateKey,
HandoffConstants.HandoffSharedStateScope,
cancellationToken);
}
public new ValueTask ResetAsync() => base.ResetAsync();
@@ -1,12 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed record class HandoffState(
TurnToken TurnToken,
string? RequestedHandoffTargetAgentId,
List<ChatMessage> Messages,
string? PreviousAgentId = null);
@@ -0,0 +1,56 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class MultiPartyConversation
{
private readonly List<ChatMessage> _history = [];
private readonly object _mutex = new();
public List<ChatMessage> CloneAllMessages()
{
lock (this._mutex)
{
return this._history.ToList();
}
}
public (ChatMessage[], int) CollectNewMessages(int bookmark)
{
lock (this._mutex)
{
int count = this._history.Count - bookmark;
if (count < 0)
{
throw new InvalidOperationException($"Bookmark value too large: {bookmark} vs count={count}");
}
return (this._history.Skip(bookmark).ToArray(), this.CurrentBookmark);
}
}
private int CurrentBookmark => this._history.Count;
public int AddMessages(IEnumerable<ChatMessage> messages)
{
lock (this._mutex)
{
this._history.AddRange(messages);
return this.CurrentBookmark;
}
}
public int AddMessage(ChatMessage message)
{
lock (this._mutex)
{
this._history.Add(message);
return this.CurrentBookmark;
}
}
}
@@ -1,27 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// This attribute indicates that a message handler streams messages during its execution.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public sealed class StreamsMessageAttribute : Attribute
{
/// <summary>
/// The type of the message that the handler yields.
/// </summary>
public Type Type { get; }
/// <summary>
/// Indicates that the message handler yields streaming messages during the course of execution.
/// </summary>
public StreamsMessageAttribute(Type type)
{
// This attribute is used to mark executors that yield messages.
this.Type = Throw.IfNull(type);
}
}
@@ -41,7 +41,7 @@ public static class WorkflowHostingExtensions
{
Dictionary<string, object?> parameters = new()
{
{ "data", request.Data}
{ "data", request.Data }
};
return new FunctionCallContent(request.RequestId, request.PortInfo.PortId, parameters);
@@ -247,7 +247,7 @@ internal sealed class WorkflowSession : AgentSession
hasMatchedResponseForStartExecutor |= string.Equals(responseExecutorId, this._workflow.StartExecutorId, StringComparison.Ordinal);
}
AIContent normalizedResponseContent = NormalizeResponseContentForDelivery(content, pendingRequest);
object normalizedResponseContent = NormalizeResponseContentForDelivery(content, pendingRequest);
externalResponses.Add((pendingRequest.CreateResponse(normalizedResponseContent), pendingRequest.RequestId));
(matchedContentIds ??= new(StringComparer.Ordinal)).Add(contentId);
}
@@ -303,14 +303,35 @@ internal sealed class WorkflowSession : AgentSession
/// <summary>
/// Rewrites workflow-facing response content back to the original agent-owned content ID.
/// </summary>
private static AIContent NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request) => content switch
private static object NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request)
{
FunctionResultContent functionResultContent when request.TryGetDataAs(out FunctionCallContent? functionCallContent)
=> CloneFunctionResultContent(functionResultContent, functionCallContent.CallId),
ToolApprovalResponseContent toolApprovalResponseContent when request.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent)
=> CloneToolApprovalResponseContent(toolApprovalResponseContent, toolApprovalRequestContent.RequestId),
_ => content,
};
switch (content)
{
// If we got a FRC, and were expecting a FRC (because the request started out as a FCC, rather than getting converted to
// on at the WorkflowSession boundary), clone it and send it in.
case FunctionResultContent functionResultContent when request.TryGetDataAs(out FunctionCallContent? functionCallContent):
return CloneFunctionResultContent(functionResultContent, functionCallContent.CallId);
case FunctionResultContent functionResultContent when !request.PortInfo.ResponseType.IsMatchPolymorphic(typeof(FunctionResultContent)):
{
object? result = functionResultContent.Result;
if (result != null)
{
if (request.PortInfo.ResponseType.IsMatchPolymorphic(result.GetType()) || result is PortableValue)
{
return result;
}
throw new InvalidOperationException($"Unexpected result type in FunctionResultContent {result.GetType()}; expecting {request.PortInfo.ResponseType}");
}
throw new NotSupportedException($"Null result is not supported when using RequestPort with non-AIContent-typed requests. {functionResultContent}");
}
case ToolApprovalResponseContent toolApprovalResponseContent when request.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent):
return CloneToolApprovalResponseContent(toolApprovalResponseContent, toolApprovalRequestContent.RequestId);
default:
return content;
}
}
/// <summary>
/// Gets the workflow-facing request ID from response content types.
@@ -0,0 +1,369 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Evaluation;
namespace Microsoft.Agents.AI;
/// <summary>
/// Extension methods for evaluating agents, responses, and workflow runs.
/// </summary>
public static partial class AgentEvaluationExtensions
{
private const string DefaultEvalName = "AgentFrameworkEval";
/// <summary>
/// Evaluates an agent by running it against test queries and scoring the responses.
/// </summary>
/// <param name="agent">The agent to evaluate.</param>
/// <param name="queries">Test queries to send to the agent.</param>
/// <param name="evaluator">The evaluator to score responses.</param>
/// <param name="evalName">Display name for this evaluation run.</param>
/// <param name="expectedOutput">
/// Optional ground-truth expected outputs, one per query. When provided,
/// must be the same length as <paramref name="queries"/>. Each value is
/// stamped on the corresponding <see cref="EvalItem.ExpectedOutput"/>.
/// </param>
/// <param name="expectedToolCalls">
/// Optional expected tool calls, one list per query. When provided,
/// must be the same length as <paramref name="queries"/>. Each list is
/// stamped on the corresponding <see cref="EvalItem.ExpectedToolCalls"/>.
/// </param>
/// <param name="splitter">
/// Optional conversation splitter to apply to all items.
/// Use <see cref="ConversationSplitters.LastTurn"/>, <see cref="ConversationSplitters.Full"/>,
/// or a custom <see cref="IConversationSplitter"/> implementation.
/// </param>
/// <param name="numRepetitions">
/// Number of times to run each query (default 1). When greater than 1, each query is invoked
/// independently N times to measure consistency. Results contain all N × queries.Count items.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Evaluation results.</returns>
public static async Task<AgentEvaluationResults> EvaluateAsync(
this AIAgent agent,
IEnumerable<string> queries,
IAgentEvaluator evaluator,
string evalName = DefaultEvalName,
IEnumerable<string>? expectedOutput = null,
IEnumerable<IEnumerable<ExpectedToolCall>>? expectedToolCalls = null,
IConversationSplitter? splitter = null,
int numRepetitions = 1,
CancellationToken cancellationToken = default)
{
var items = await RunAgentForEvalAsync(agent, queries, expectedOutput, expectedToolCalls, splitter, numRepetitions, cancellationToken).ConfigureAwait(false);
return await evaluator.EvaluateAsync(items, evalName, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Evaluates an agent using an MEAI evaluator directly.
/// </summary>
/// <param name="agent">The agent to evaluate.</param>
/// <param name="queries">Test queries to send to the agent.</param>
/// <param name="evaluator">The MEAI evaluator (e.g., <c>RelevanceEvaluator</c>, <c>CompositeEvaluator</c>).</param>
/// <param name="chatConfiguration">Chat configuration for the MEAI evaluator (includes the judge model).</param>
/// <param name="evalName">Display name for this evaluation run.</param>
/// <param name="expectedOutput">
/// Optional ground-truth expected outputs, one per query.
/// </param>
/// <param name="expectedToolCalls">
/// Optional expected tool calls, one list per query.
/// </param>
/// <param name="splitter">
/// Optional conversation splitter to apply to all items.
/// Use <see cref="ConversationSplitters.LastTurn"/>, <see cref="ConversationSplitters.Full"/>,
/// or a custom <see cref="IConversationSplitter"/> implementation.
/// </param>
/// <param name="numRepetitions">
/// Number of times to run each query (default 1). When greater than 1, each query is invoked
/// independently N times to measure consistency.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Evaluation results.</returns>
public static async Task<AgentEvaluationResults> EvaluateAsync(
this AIAgent agent,
IEnumerable<string> queries,
IEvaluator evaluator,
ChatConfiguration chatConfiguration,
string evalName = DefaultEvalName,
IEnumerable<string>? expectedOutput = null,
IEnumerable<IEnumerable<ExpectedToolCall>>? expectedToolCalls = null,
IConversationSplitter? splitter = null,
int numRepetitions = 1,
CancellationToken cancellationToken = default)
{
var wrapped = new MeaiEvaluatorAdapter(evaluator, chatConfiguration);
return await agent.EvaluateAsync(queries, wrapped, evalName, expectedOutput, expectedToolCalls, splitter, numRepetitions, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Evaluates an agent by running it against test queries with multiple evaluators.
/// </summary>
/// <param name="agent">The agent to evaluate.</param>
/// <param name="queries">Test queries to send to the agent.</param>
/// <param name="evaluators">The evaluators to score responses.</param>
/// <param name="evalName">Display name for this evaluation run.</param>
/// <param name="expectedOutput">
/// Optional ground-truth expected outputs, one per query.
/// </param>
/// <param name="expectedToolCalls">
/// Optional expected tool calls, one list per query.
/// </param>
/// <param name="splitter">
/// Optional conversation splitter to apply to all items.
/// Use <see cref="ConversationSplitters.LastTurn"/>, <see cref="ConversationSplitters.Full"/>,
/// or a custom <see cref="IConversationSplitter"/> implementation.
/// </param>
/// <param name="numRepetitions">
/// Number of times to run each query (default 1). When greater than 1, each query is invoked
/// independently N times to measure consistency.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One result per evaluator.</returns>
public static async Task<IReadOnlyList<AgentEvaluationResults>> EvaluateAsync(
this AIAgent agent,
IEnumerable<string> queries,
IEnumerable<IAgentEvaluator> evaluators,
string evalName = DefaultEvalName,
IEnumerable<string>? expectedOutput = null,
IEnumerable<IEnumerable<ExpectedToolCall>>? expectedToolCalls = null,
IConversationSplitter? splitter = null,
int numRepetitions = 1,
CancellationToken cancellationToken = default)
{
var items = await RunAgentForEvalAsync(agent, queries, expectedOutput, expectedToolCalls, splitter, numRepetitions, cancellationToken).ConfigureAwait(false);
var results = new List<AgentEvaluationResults>();
foreach (var evaluator in evaluators)
{
var result = await evaluator.EvaluateAsync(items, evalName, cancellationToken).ConfigureAwait(false);
results.Add(result);
}
return results;
}
/// <summary>
/// Evaluates pre-existing agent responses without re-running the agent.
/// </summary>
/// <param name="agent">The agent (used for tool definitions).</param>
/// <param name="responses">Pre-existing agent responses.</param>
/// <param name="queries">The queries that produced each response (must match count).</param>
/// <param name="evaluator">The evaluator to score responses.</param>
/// <param name="evalName">Display name for this evaluation run.</param>
/// <param name="expectedOutput">
/// Optional ground-truth expected outputs, one per query.
/// </param>
/// <param name="expectedToolCalls">
/// Optional expected tool calls, one list per query.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Evaluation results.</returns>
public static async Task<AgentEvaluationResults> EvaluateAsync(
this AIAgent agent,
IEnumerable<AgentResponse> responses,
IEnumerable<string> queries,
IAgentEvaluator evaluator,
string evalName = DefaultEvalName,
IEnumerable<string>? expectedOutput = null,
IEnumerable<IEnumerable<ExpectedToolCall>>? expectedToolCalls = null,
CancellationToken cancellationToken = default)
{
var items = BuildItemsFromResponses(agent, responses, queries, expectedOutput, expectedToolCalls);
return await evaluator.EvaluateAsync(items, evalName, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Evaluates pre-existing agent responses using an MEAI evaluator directly.
/// </summary>
/// <param name="agent">The agent (used for tool definitions).</param>
/// <param name="responses">Pre-existing agent responses.</param>
/// <param name="queries">The queries that produced each response (must match count).</param>
/// <param name="evaluator">The MEAI evaluator.</param>
/// <param name="chatConfiguration">Chat configuration for the MEAI evaluator.</param>
/// <param name="evalName">Display name for this evaluation run.</param>
/// <param name="expectedOutput">
/// Optional ground-truth expected outputs, one per query.
/// </param>
/// <param name="expectedToolCalls">
/// Optional expected tool calls, one list per query.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Evaluation results.</returns>
public static async Task<AgentEvaluationResults> EvaluateAsync(
this AIAgent agent,
IEnumerable<AgentResponse> responses,
IEnumerable<string> queries,
IEvaluator evaluator,
ChatConfiguration chatConfiguration,
string evalName = DefaultEvalName,
IEnumerable<string>? expectedOutput = null,
IEnumerable<IEnumerable<ExpectedToolCall>>? expectedToolCalls = null,
CancellationToken cancellationToken = default)
{
var wrapped = new MeaiEvaluatorAdapter(evaluator, chatConfiguration);
return await agent.EvaluateAsync(responses, queries, wrapped, evalName, expectedOutput, expectedToolCalls, cancellationToken).ConfigureAwait(false);
}
internal static List<EvalItem> BuildItemsFromResponses(
AIAgent agent,
IEnumerable<AgentResponse> responses,
IEnumerable<string> queries,
IEnumerable<string>? expectedOutput,
IEnumerable<IEnumerable<ExpectedToolCall>>? expectedToolCalls)
{
var responseList = responses.ToList();
var queryList = queries.ToList();
var expectedList = expectedOutput?.ToList();
var expectedToolCallsList = expectedToolCalls?.ToList();
if (responseList.Count != queryList.Count)
{
throw new ArgumentException(
$"Found {queryList.Count} queries but {responseList.Count} responses. Counts must match.");
}
if (expectedList != null && expectedList.Count != queryList.Count)
{
throw new ArgumentException(
$"Found {queryList.Count} queries but {expectedList.Count} expectedOutput values. Counts must match.");
}
if (expectedToolCallsList != null && expectedToolCallsList.Count != queryList.Count)
{
throw new ArgumentException(
$"Found {queryList.Count} queries but {expectedToolCallsList.Count} expectedToolCalls lists. Counts must match.");
}
var items = new List<EvalItem>();
for (int i = 0; i < responseList.Count; i++)
{
var query = queryList[i];
var response = responseList[i];
var messages = new List<ChatMessage>
{
new(ChatRole.User, query),
};
messages.AddRange(response.Messages);
var item = BuildEvalItem(query, response, messages, agent);
if (expectedList != null)
{
item.ExpectedOutput = expectedList[i];
}
if (expectedToolCallsList != null)
{
item.ExpectedToolCalls = expectedToolCallsList[i].ToList();
}
items.Add(item);
}
return items;
}
private static async Task<List<EvalItem>> RunAgentForEvalAsync(
AIAgent agent,
IEnumerable<string> queries,
IEnumerable<string>? expectedOutput,
IEnumerable<IEnumerable<ExpectedToolCall>>? expectedToolCalls,
IConversationSplitter? splitter,
int numRepetitions,
CancellationToken cancellationToken)
{
if (numRepetitions < 1)
{
throw new ArgumentException($"numRepetitions must be >= 1, got {numRepetitions}.", nameof(numRepetitions));
}
var items = new List<EvalItem>();
var queryList = queries.ToList();
var expectedList = expectedOutput?.ToList();
var expectedToolCallsList = expectedToolCalls?.ToList();
if (expectedList != null && expectedList.Count != queryList.Count)
{
throw new ArgumentException(
$"Got {queryList.Count} queries but {expectedList.Count} expectedOutput values. Counts must match.");
}
if (expectedToolCallsList != null && expectedToolCallsList.Count != queryList.Count)
{
throw new ArgumentException(
$"Got {queryList.Count} queries but {expectedToolCallsList.Count} expectedToolCalls lists. Counts must match.");
}
for (int rep = 0; rep < numRepetitions; rep++)
{
for (int i = 0; i < queryList.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var query = queryList[i];
var messages = new List<ChatMessage>
{
new(ChatRole.User, query),
};
var response = await agent.RunAsync(messages, cancellationToken: cancellationToken).ConfigureAwait(false);
var item = BuildEvalItem(query, response, messages, agent);
item.Splitter = splitter;
if (expectedList != null)
{
item.ExpectedOutput = expectedList[i];
}
if (expectedToolCallsList != null)
{
item.ExpectedToolCalls = expectedToolCallsList[i].ToList();
}
items.Add(item);
}
}
return items;
}
internal static EvalItem BuildEvalItem(
string query,
AgentResponse response,
List<ChatMessage> messages,
AIAgent? agent)
{
// Build conversation from existing messages plus any new response messages
var conversation = new List<ChatMessage>(messages);
foreach (var msg in response.Messages)
{
if (!conversation.Contains(msg))
{
conversation.Add(msg);
}
}
var item = new EvalItem(query, response.Text, conversation)
{
RawResponse = new ChatResponse(response.Messages.LastOrDefault()
?? new ChatMessage(ChatRole.Assistant, response.Text)),
};
// Extract tool definitions from the agent (mirrors Python's to_eval_item(agent=...))
if (agent is not null)
{
var chatOptions = agent.GetService<ChatOptions>();
if (chatOptions?.Tools is { Count: > 0 } tools)
{
item.Tools = tools.ToList().AsReadOnly();
}
}
return item;
}
}
@@ -0,0 +1,143 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI.Evaluation;
namespace Microsoft.Agents.AI;
/// <summary>
/// Aggregate evaluation results across multiple items.
/// </summary>
public sealed class AgentEvaluationResults
{
private readonly List<EvaluationResult> _items;
/// <summary>
/// Initializes a new instance of the <see cref="AgentEvaluationResults"/> class.
/// </summary>
/// <param name="providerName">Name of the evaluation provider.</param>
/// <param name="items">Per-item MEAI evaluation results.</param>
/// <param name="inputItems">The original eval items that were evaluated, for auditing.</param>
public AgentEvaluationResults(string providerName, IEnumerable<EvaluationResult> items, IReadOnlyList<EvalItem>? inputItems = null)
{
this.ProviderName = providerName;
this._items = new List<EvaluationResult>(items);
this.InputItems = inputItems;
}
/// <summary>Gets the evaluation provider name.</summary>
public string ProviderName { get; }
/// <summary>Gets the portal URL for viewing results (Foundry only).</summary>
public Uri? ReportUrl { get; set; }
/// <summary>Gets the Foundry evaluation ID (Foundry only).</summary>
public string? EvalId { get; set; }
/// <summary>Gets the Foundry evaluation run ID (Foundry only).</summary>
public string? RunId { get; set; }
/// <summary>Gets the evaluation run status (e.g., "completed", "failed", "canceled", "timeout").</summary>
public string? Status { get; set; }
/// <summary>Gets error details when the evaluation run failed.</summary>
public string? Error { get; set; }
/// <summary>Gets the per-item MEAI evaluation results.</summary>
public IReadOnlyList<EvaluationResult> Items => this._items;
/// <summary>
/// Gets the original eval items that produced these results, for auditing.
/// Each entry corresponds positionally to <see cref="Items"/> — <c>InputItems[i]</c>
/// is the query/response that produced <c>Items[i]</c>.
/// </summary>
public IReadOnlyList<EvalItem>? InputItems { get; }
/// <summary>Gets per-agent results for workflow evaluations.</summary>
public IReadOnlyDictionary<string, AgentEvaluationResults>? SubResults { get; set; }
/// <summary>Gets per-evaluator pass/fail breakdown (Foundry only).</summary>
public IReadOnlyDictionary<string, PerEvaluatorResult>? PerEvaluator { get; set; }
/// <summary>
/// Gets detailed per-item results from the Foundry output_items API,
/// including individual evaluator scores, error info, and token usage.
/// </summary>
public IReadOnlyList<EvalItemResult>? DetailedItems { get; set; }
/// <summary>Gets the number of items that passed.</summary>
public int Passed => this._items.Count(ItemPassed);
/// <summary>Gets the number of items that failed.</summary>
public int Failed => this._items.Count(i => !ItemPassed(i));
/// <summary>Gets the total number of items evaluated.</summary>
public int Total => this._items.Count;
/// <summary>Gets whether all items passed.</summary>
public bool AllPassed
{
get
{
if (this.SubResults is not null)
{
return this.SubResults.Values.All(s => s.AllPassed)
&& (this.Total == 0 || this.Failed == 0);
}
return this.Total > 0 && this.Failed == 0;
}
}
/// <summary>
/// Asserts that all items passed. Throws <see cref="InvalidOperationException"/> on failure.
/// </summary>
/// <param name="message">Optional custom failure message.</param>
/// <exception cref="InvalidOperationException">Thrown when any items failed.</exception>
public void AssertAllPassed(string? message = null)
{
if (!this.AllPassed)
{
var detail = message ?? $"{this.ProviderName}: {this.Passed} passed, {this.Failed} failed out of {this.Total}.";
if (this.ReportUrl is not null)
{
detail += $" See {this.ReportUrl} for details.";
}
if (this.SubResults is not null)
{
var failedAgents = this.SubResults
.Where(kvp => !kvp.Value.AllPassed)
.Select(kvp => kvp.Key);
detail += $" Failed agents: {string.Join(", ", failedAgents)}.";
}
throw new InvalidOperationException(detail);
}
}
private static bool ItemPassed(EvaluationResult result)
{
foreach (var metric in result.Metrics.Values)
{
// Trust the evaluator's own pass/fail determination first.
if (metric.Interpretation?.Failed == true)
{
return false;
}
// A boolean false is unambiguous — the check failed.
if (metric is BooleanMetric boolean && boolean.Value == false)
{
return false;
}
// Numeric metrics without Interpretation are informational scores;
// the evaluator should set Interpretation if it wants pass/fail semantics.
}
return result.Metrics.Count > 0;
}
}
@@ -0,0 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI;
/// <summary>
/// Result of a single check on a single evaluation item.
/// </summary>
/// <param name="Passed">Whether the check passed.</param>
/// <param name="Reason">Human-readable explanation.</param>
/// <param name="CheckName">Name of the check that produced this result.</param>
public sealed record EvalCheckResult(bool Passed, string Reason, string CheckName);
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI;
/// <summary>
/// Delegate for a synchronous evaluation check on a single item.
/// </summary>
/// <param name="item">The evaluation item.</param>
/// <returns>The check result.</returns>
public delegate EvalCheckResult EvalCheck(EvalItem item);
@@ -0,0 +1,328 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Specifies how <see cref="EvalChecks.ToolCalledCheck(ToolCalledMode, string[])"/> matches tool names.
/// </summary>
public enum ToolCalledMode
{
/// <summary>All specified tools must have been called.</summary>
All,
/// <summary>At least one of the specified tools must have been called.</summary>
Any,
}
/// <summary>
/// Built-in check functions for common evaluation patterns.
/// </summary>
public static class EvalChecks
{
/// <summary>
/// Creates a check that verifies the response contains all specified keywords.
/// </summary>
/// <param name="keywords">Keywords that must appear in the response.</param>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck KeywordCheck(params string[] keywords)
{
return KeywordCheck(caseSensitive: false, keywords);
}
/// <summary>
/// Creates a check that verifies the response contains all specified keywords.
/// </summary>
/// <param name="caseSensitive">Whether the comparison is case-sensitive.</param>
/// <param name="keywords">Keywords that must appear in the response.</param>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck KeywordCheck(bool caseSensitive, params string[] keywords)
{
return (EvalItem item) =>
{
var comparison = caseSensitive
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase;
var missing = keywords
.Where(kw => !item.Response.Contains(kw, comparison))
.ToList();
var passed = missing.Count == 0;
var reason = passed
? $"All keywords found: {string.Join(", ", keywords)}"
: $"Missing keywords: {string.Join(", ", missing)}";
return new EvalCheckResult(passed, reason, "keyword_check");
};
}
/// <summary>
/// Creates a check that verifies specific tools were called in the conversation.
/// All specified tools must have been called.
/// </summary>
/// <param name="toolNames">Tool names that must appear in the conversation.</param>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck ToolCalledCheck(params string[] toolNames)
{
return ToolCalledCheck(ToolCalledMode.All, toolNames);
}
/// <summary>
/// Creates a check that verifies specific tools were called in the conversation.
/// </summary>
/// <param name="mode">Whether <see cref="ToolCalledMode.All"/> or <see cref="ToolCalledMode.Any"/> of the specified tools must be called.</param>
/// <param name="toolNames">Tool names to check for.</param>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck ToolCalledCheck(ToolCalledMode mode, params string[] toolNames)
{
return (EvalItem item) =>
{
var calledTools = GetCalledTools(item);
if (mode == ToolCalledMode.Any)
{
var found = toolNames.Where(t => calledTools.Contains(t)).ToList();
var passed = found.Count > 0;
var reason = passed
? $"Called: {string.Join(", ", found)}"
: $"None of expected tools called: {string.Join(", ", toolNames)}";
return new EvalCheckResult(passed, reason, "tool_called_check");
}
var missing = toolNames.Where(t => !calledTools.Contains(t)).ToList();
var allPassed = missing.Count == 0;
var allReason = allPassed
? $"All tools called: {string.Join(", ", toolNames)}"
: $"Missing tool calls: {string.Join(", ", missing)}";
return new EvalCheckResult(allPassed, allReason, "tool_called_check");
};
}
/// <summary>
/// A check that verifies at least one tool was called in the conversation.
/// </summary>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck ToolCallsPresent()
{
return (EvalItem item) =>
{
var calledTools = GetCalledTools(item);
var passed = calledTools.Count > 0;
var reason = passed
? $"Tools called: {string.Join(", ", calledTools)}"
: "No tool calls found in conversation";
return new EvalCheckResult(passed, reason, "tool_calls_present");
};
}
/// <summary>
/// A check that verifies expected tool calls match on name and optionally arguments.
/// </summary>
/// <remarks>
/// <para>
/// For each expected tool call, finds matching calls in the conversation by name.
/// If <see cref="ExpectedToolCall.Arguments"/> is provided, checks that the actual
/// arguments contain all expected key-value pairs (subset match — extra actual arguments are OK).
/// </para>
/// <para>If no expected tool calls are set on the item, the check passes.</para>
/// </remarks>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck ToolCallArgsMatch()
{
return (EvalItem item) =>
{
var expected = item.ExpectedToolCalls;
if (expected is null || expected.Count == 0)
{
return new EvalCheckResult(true, "No expected tool calls specified.", "tool_call_args_match");
}
var actualCalls = GetCalledToolsWithArgs(item);
int matched = 0;
var details = new List<string>();
foreach (var exp in expected)
{
var matching = actualCalls.Where(c => string.Equals(c.Name, exp.Name, StringComparison.OrdinalIgnoreCase)).ToList();
if (matching.Count == 0)
{
details.Add($" {exp.Name}: not called");
continue;
}
if (exp.Arguments is null)
{
matched++;
details.Add($" {exp.Name}: called (args not checked)");
continue;
}
// Subset match — all expected keys present with expected values
bool found = false;
foreach (var call in matching)
{
if (call.Arguments is not null
&& exp.Arguments.All(kvp =>
call.Arguments.TryGetValue(kvp.Key, out var actual)
&& Equals(actual, kvp.Value)))
{
found = true;
break;
}
}
if (found)
{
matched++;
details.Add($" {exp.Name}: args match");
}
else
{
details.Add($" {exp.Name}: args mismatch");
}
}
var passed = matched == expected.Count;
var reason = $"Tool call args match: {matched}/{expected.Count}\n{string.Join("\n", details)}";
return new EvalCheckResult(passed, reason, "tool_call_args_match");
};
}
/// <summary>
/// Creates a check that verifies the response is non-empty and meets a minimum length.
/// </summary>
/// <param name="minLength">Minimum response length (default 1).</param>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck NonEmpty(int minLength = 1)
{
return (EvalItem item) =>
{
var trimmed = item.Response.Trim();
var passed = trimmed.Length >= minLength;
var reason = passed
? $"Response length {trimmed.Length} meets minimum {minLength}"
: $"Response length {trimmed.Length} is below minimum {minLength}";
return new EvalCheckResult(passed, reason, "non_empty");
};
}
/// <summary>
/// Creates a check that verifies the response contains the expected output text.
/// </summary>
/// <param name="caseSensitive">Whether the comparison is case-sensitive (default false).</param>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck ContainsExpected(bool caseSensitive = false)
{
return (EvalItem item) =>
{
if (string.IsNullOrEmpty(item.ExpectedOutput))
{
return new EvalCheckResult(false, "ExpectedOutput is not set; check cannot be applied.", "contains_expected");
}
var comparison = caseSensitive
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase;
var passed = item.Response.Contains(item.ExpectedOutput, comparison);
var reason = passed
? $"Response contains expected output: \"{item.ExpectedOutput}\""
: $"Response does not contain expected output: \"{item.ExpectedOutput}\"";
return new EvalCheckResult(passed, reason, "contains_expected");
};
}
/// <summary>
/// A check that verifies the conversation contains at least one image
/// (<see cref="DataContent"/> or <see cref="UriContent"/> with an image media type).
/// </summary>
/// <returns>An <see cref="EvalCheck"/> delegate.</returns>
public static EvalCheck HasImageContent()
{
return (EvalItem item) =>
{
var passed = item.HasImageContent;
var reason = passed
? "Conversation contains image content"
: "No image content found in conversation";
return new EvalCheckResult(passed, reason, "has_image_content");
};
}
private static HashSet<string> GetCalledTools(EvalItem item)
{
var calledTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var message in item.Conversation)
{
foreach (var content in message.Contents)
{
if (content is FunctionCallContent functionCall)
{
calledTools.Add(functionCall.Name);
}
}
}
return calledTools;
}
private static List<(string Name, IReadOnlyDictionary<string, object>? Arguments)> GetCalledToolsWithArgs(EvalItem item)
{
var calls = new List<(string Name, IReadOnlyDictionary<string, object>? Arguments)>();
foreach (var message in item.Conversation)
{
foreach (var content in message.Contents)
{
if (content is FunctionCallContent functionCall)
{
IDictionary<string, object?>? rawArgs = functionCall.Arguments;
IReadOnlyDictionary<string, object>? args = null;
if (rawArgs is not null)
{
var dict = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in rawArgs)
{
if (kvp.Value is not null)
{
// Normalize JsonElement values to their .NET equivalents for comparison
dict[kvp.Key] = kvp.Value is JsonElement je ? UnwrapJsonElement(je) : kvp.Value;
}
}
args = dict;
}
calls.Add((functionCall.Name, args));
}
}
}
return calls;
}
private static object UnwrapJsonElement(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString()!,
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => element.ToString(),
};
}
}
@@ -0,0 +1,211 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provider-agnostic data for a single evaluation item.
/// </summary>
public sealed class EvalItem
{
/// <summary>
/// Initializes a new instance of the <see cref="EvalItem"/> class.
/// </summary>
/// <param name="query">The user query.</param>
/// <param name="response">The agent response text.</param>
/// <param name="conversation">The full conversation as <see cref="ChatMessage"/> list.</param>
public EvalItem(string query, string response, IReadOnlyList<ChatMessage> conversation)
{
this.Query = query;
this.Response = response;
this.Conversation = conversation;
}
/// <summary>
/// Initializes a new instance of the <see cref="EvalItem"/> class from a conversation,
/// deriving query and response text via the default splitter.
/// </summary>
/// <remarks>
/// Use this constructor when the conversation contains multimodal content (images, etc.)
/// that can't be represented as plain text. The query is extracted from the last user
/// message text, and the response from the last assistant message text.
/// </remarks>
/// <param name="conversation">The full conversation as <see cref="ChatMessage"/> list.</param>
/// <param name="splitter">
/// Optional splitter to determine query/response boundaries.
/// Defaults to <see cref="ConversationSplitters.LastTurn"/>.
/// </param>
public EvalItem(IReadOnlyList<ChatMessage> conversation, IConversationSplitter? splitter = null)
{
this.Conversation = conversation;
this.Splitter = splitter;
var effective = splitter ?? ConversationSplitters.LastTurn;
var (queryMessages, responseMessages) = effective.Split(conversation);
this.Query = queryMessages.LastOrDefault(m => m.Role == ChatRole.User)?.Text ?? string.Empty;
this.Response = string.Join(
" ",
responseMessages
.Where(m => m.Role == ChatRole.Assistant && !string.IsNullOrEmpty(m.Text))
.Select(m => m.Text));
}
/// <summary>
/// Initializes a new instance of the <see cref="EvalItem"/> class from query and response
/// strings, automatically building a minimal conversation.
/// </summary>
/// <remarks>
/// Use this constructor for simple text-only evaluations where you don't need
/// a full conversation history.
/// </remarks>
/// <param name="query">The user query.</param>
/// <param name="response">The agent response text.</param>
public EvalItem(string query, string response)
{
this.Query = query;
this.Response = response;
this.Conversation = new List<ChatMessage>
{
new(ChatRole.User, query),
new(ChatRole.Assistant, response),
};
}
/// <summary>Gets the user query.</summary>
public string Query { get; }
/// <summary>Gets the agent response text.</summary>
public string Response { get; }
/// <summary>Gets the full conversation history.</summary>
/// <remarks>
/// The conversation preserves all content types including images
/// (<see cref="DataContent"/>, <see cref="UriContent"/> with image media types).
/// Use this property in custom <see cref="EvalCheck"/> functions
/// to inspect multimodal content that isn't captured in the
/// text-only <see cref="Query"/> and <see cref="Response"/> properties.
/// </remarks>
public IReadOnlyList<ChatMessage> Conversation { get; }
/// <summary>
/// Gets whether any message in the conversation contains image content.
/// </summary>
/// <remarks>
/// Checks for <see cref="DataContent"/> or <see cref="UriContent"/> with an image media type.
/// Useful in <see cref="EvalCheck"/> functions to verify multimodal content is present.
/// </remarks>
public bool HasImageContent =>
this.Conversation.Any(m =>
m.Contents.Any(c =>
(c is DataContent dc && dc.HasTopLevelMediaType("image"))
|| (c is UriContent uc && uc.HasTopLevelMediaType("image"))));
/// <summary>Gets or sets the tools available to the agent.</summary>
public IReadOnlyList<AITool>? Tools { get; set; }
/// <summary>Gets or sets grounding context for evaluation.</summary>
public string? Context { get; set; }
/// <summary>Gets or sets the expected output for ground-truth comparison.</summary>
public string? ExpectedOutput { get; set; }
/// <summary>
/// Gets or sets the expected tool calls for tool-correctness evaluation.
/// </summary>
/// <remarks>
/// Each entry describes a tool call the agent should make. The evaluator
/// decides matching semantics (ordering, extras, argument checking).
/// See <see cref="ExpectedToolCall"/>.
/// </remarks>
public IReadOnlyList<ExpectedToolCall>? ExpectedToolCalls { get; set; }
/// <summary>Gets or sets the raw chat response for MEAI evaluators.</summary>
public ChatResponse? RawResponse { get; set; }
/// <summary>
/// Gets or sets the conversation splitter for this item.
/// </summary>
/// <remarks>
/// When set by orchestration functions (e.g. <c>EvaluateAsync(splitter: ...)</c>),
/// this is used as the default by <see cref="Split(IConversationSplitter?)"/>.
/// Priority: explicit <c>Split(splitter)</c> argument &gt;
/// <see cref="Splitter"/> &gt; <see cref="ConversationSplitters.LastTurn"/>.
/// </remarks>
public IConversationSplitter? Splitter { get; set; }
/// <summary>
/// Splits the conversation into query messages and response messages.
/// </summary>
/// <param name="splitter">
/// The splitter to use. When <c>null</c>, uses <see cref="Splitter"/>
/// if set, otherwise <see cref="ConversationSplitters.LastTurn"/>.
/// </param>
/// <returns>A tuple of (query messages, response messages).</returns>
public (IReadOnlyList<ChatMessage> QueryMessages, IReadOnlyList<ChatMessage> ResponseMessages) Split(
IConversationSplitter? splitter = null)
{
var effective = splitter ?? this.Splitter ?? ConversationSplitters.LastTurn;
return effective.Split(this.Conversation);
}
/// <summary>
/// Splits a multi-turn conversation into one <see cref="EvalItem"/> per user turn.
/// </summary>
/// <remarks>
/// Each user message starts a new turn. The resulting item has cumulative context:
/// query messages contain the full conversation up to and including that user message,
/// and the response is everything up to the next user message.
/// </remarks>
/// <param name="conversation">The full conversation to split.</param>
/// <param name="tools">Optional tools available to the agent.</param>
/// <param name="context">Optional grounding context.</param>
/// <returns>A list of eval items, one per user turn.</returns>
public static IReadOnlyList<EvalItem> PerTurnItems(
IReadOnlyList<ChatMessage> conversation,
IReadOnlyList<AITool>? tools = null,
string? context = null)
{
var items = new List<EvalItem>();
var userIndices = new List<int>();
for (int i = 0; i < conversation.Count; i++)
{
if (conversation[i].Role == ChatRole.User)
{
userIndices.Add(i);
}
}
for (int t = 0; t < userIndices.Count; t++)
{
int userIdx = userIndices[t];
int nextBoundary = t + 1 < userIndices.Count
? userIndices[t + 1]
: conversation.Count;
var responseMessages = conversation.Skip(userIdx + 1).Take(nextBoundary - userIdx - 1).ToList();
var query = conversation[userIdx].Text ?? string.Empty;
var responseText = string.Join(
" ",
responseMessages
.Where(m => m.Role == ChatRole.Assistant && !string.IsNullOrEmpty(m.Text))
.Select(m => m.Text));
var fullSlice = conversation.Take(nextBoundary).ToList();
var item = new EvalItem(query, responseText, fullSlice)
{
Tools = tools,
Context = context,
};
items.Add(item);
}
return items;
}
}
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
namespace Microsoft.Agents.AI;
/// <summary>
/// Per-item result from a Foundry evaluation run, with individual evaluator scores and error details.
/// </summary>
public sealed class EvalItemResult
{
/// <summary>
/// Initializes a new instance of the <see cref="EvalItemResult"/> class.
/// </summary>
/// <param name="itemId">The output item ID from the evaluation API.</param>
/// <param name="status">The item evaluation status (e.g., "pass", "fail", "error").</param>
/// <param name="scores">Per-evaluator score results.</param>
public EvalItemResult(string itemId, string status, IReadOnlyList<EvalScoreResult> scores)
{
this.ItemId = itemId;
this.Status = status;
this.Scores = scores;
}
/// <summary>Gets the output item ID from the evaluation API.</summary>
public string ItemId { get; }
/// <summary>Gets the item evaluation status (e.g., "pass", "fail", "error", "errored").</summary>
public string Status { get; }
/// <summary>Gets the per-evaluator score results.</summary>
public IReadOnlyList<EvalScoreResult> Scores { get; }
/// <summary>Gets or sets an error code when the item evaluation errored.</summary>
public string? ErrorCode { get; set; }
/// <summary>Gets or sets an error message when the item evaluation errored.</summary>
public string? ErrorMessage { get; set; }
/// <summary>Gets or sets the response ID from the evaluation API (e.g., for response-based evals).</summary>
public string? ResponseId { get; set; }
/// <summary>Gets or sets the input text echoed back by the evaluation API.</summary>
public string? InputText { get; set; }
/// <summary>Gets or sets the output text echoed back by the evaluation API.</summary>
public string? OutputText { get; set; }
/// <summary>Gets or sets token usage information from the evaluation.</summary>
public IReadOnlyDictionary<string, int>? TokenUsage { get; set; }
/// <summary>Gets whether this item is in an error state.</summary>
public bool IsError => this.Status is "error" or "errored";
/// <summary>Gets whether this item passed all evaluators.</summary>
public bool IsPassed => this.Scores.Count > 0 && this.Scores.All(s => s.Passed == true);
/// <summary>Gets whether this item failed any evaluator.</summary>
public bool IsFailed => this.Scores.Any(s => s.Passed == false);
}
/// <summary>
/// A single evaluator's score on one evaluation item.
/// </summary>
/// <param name="Name">The evaluator name that produced this score.</param>
/// <param name="Score">The numeric score value.</param>
/// <param name="Passed">Whether the evaluator considered this a pass, or null if not determined.</param>
public record EvalScoreResult(string Name, double Score, bool? Passed = null);
/// <summary>
/// Per-evaluator pass/fail breakdown from an evaluation run.
/// </summary>
/// <param name="Passed">Number of items that passed for this evaluator.</param>
/// <param name="Failed">Number of items that failed for this evaluator.</param>
public record PerEvaluatorResult(int Passed, int Failed);
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
namespace Microsoft.Agents.AI;
/// <summary>
/// A tool call that an agent is expected to make.
/// </summary>
/// <remarks>
/// Used with <c>EvaluateAsync</c> to assert that the agent called the correct tools.
/// The evaluator decides matching semantics (order, extras, argument checking);
/// this type is pure data.
/// </remarks>
/// <param name="Name">The tool/function name (e.g. <c>"get_weather"</c>).</param>
/// <param name="Arguments">
/// Expected arguments. <c>null</c> means "don't check arguments".
/// When provided, evaluators typically do subset matching (all expected keys must be present).
/// </param>
public record ExpectedToolCall(string Name, IReadOnlyDictionary<string, object>? Arguments = null);
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI;
/// <summary>
/// Factory for creating <see cref="EvalCheck"/> delegates from typed lambda functions.
/// </summary>
public static class FunctionEvaluator
{
/// <summary>
/// Creates a check from a function that takes the response text and returns a bool.
/// </summary>
/// <param name="name">Check name for reporting.</param>
/// <param name="check">Function that returns true if the response passes.</param>
public static EvalCheck Create(string name, Func<string, bool> check)
{
return (EvalItem item) =>
{
var passed = check(item.Response);
return new EvalCheckResult(passed, passed ? "Passed" : "Failed", name);
};
}
/// <summary>
/// Creates a check from a function that takes response and expected text.
/// </summary>
/// <param name="name">Check name for reporting.</param>
/// <param name="check">Function that returns true if the response passes.</param>
public static EvalCheck Create(string name, Func<string, string?, bool> check)
{
return (EvalItem item) =>
{
var passed = check(item.Response, item.ExpectedOutput);
return new EvalCheckResult(passed, passed ? "Passed" : "Failed", name);
};
}
/// <summary>
/// Creates a check from a function that takes the full <see cref="EvalItem"/>.
/// </summary>
/// <param name="name">Check name for reporting.</param>
/// <param name="check">Function that returns true if the item passes.</param>
public static EvalCheck Create(string name, Func<EvalItem, bool> check)
{
return (EvalItem item) =>
{
var passed = check(item);
return new EvalCheckResult(passed, passed ? "Passed" : "Failed", name);
};
}
/// <summary>
/// Creates a check from a function that takes the full <see cref="EvalItem"/>
/// and returns a <see cref="EvalCheckResult"/>.
/// </summary>
/// <param name="name">Check name (used as fallback if the result has no name).</param>
/// <param name="check">Function that returns a full check result.</param>
public static EvalCheck Create(string name, Func<EvalItem, EvalCheckResult> check)
{
return (EvalItem item) =>
{
var result = check(item);
return result with { CheckName = result.CheckName ?? name };
};
}
}
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI;
/// <summary>
/// Batch-oriented evaluator interface for agent evaluation.
/// </summary>
/// <remarks>
/// Unlike MEAI's <c>IEvaluator</c> which evaluates one item at a time,
/// <see cref="IAgentEvaluator"/> evaluates a batch of items. This enables
/// efficient cloud-based evaluation (e.g., Foundry) and aggregate result computation.
/// </remarks>
public interface IAgentEvaluator
{
/// <summary>Gets the evaluator name.</summary>
string Name { get; }
/// <summary>
/// Evaluates a batch of items and returns aggregate results.
/// </summary>
/// <param name="items">The items to evaluate.</param>
/// <param name="evalName">A display name for this evaluation run.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Aggregate evaluation results.</returns>
Task<AgentEvaluationResults> EvaluateAsync(
IReadOnlyList<EvalItem> items,
string evalName = "Agent Framework Eval",
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,103 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Strategy for splitting a conversation into query and response halves for evaluation.
/// </summary>
/// <remarks>
/// Use one of the built-in splitters from <see cref="ConversationSplitters"/> or implement
/// your own for domain-specific splitting logic (e.g., splitting before a memory-retrieval
/// tool call to evaluate recall quality).
/// </remarks>
public interface IConversationSplitter
{
/// <summary>
/// Splits a conversation into query messages and response messages.
/// </summary>
/// <param name="conversation">The full conversation to split.</param>
/// <returns>A tuple of (query messages, response messages).</returns>
(IReadOnlyList<ChatMessage> QueryMessages, IReadOnlyList<ChatMessage> ResponseMessages) Split(
IReadOnlyList<ChatMessage> conversation);
}
/// <summary>
/// Built-in conversation splitters for common evaluation patterns.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item><see cref="LastTurn"/>: Evaluates whether the agent answered the <em>latest</em> question well.</item>
/// <item><see cref="Full"/>: Evaluates whether the <em>whole conversation trajectory</em> served the original request.</item>
/// </list>
/// For custom splits, implement <see cref="IConversationSplitter"/> directly.
/// </remarks>
public static class ConversationSplitters
{
/// <summary>
/// Split at the last user message. Everything up to and including that message
/// is the query; everything after is the response. This is the default strategy.
/// </summary>
public static IConversationSplitter LastTurn { get; } = new LastTurnSplitter();
/// <summary>
/// The first user message (and any preceding system messages) is the query;
/// the entire remainder of the conversation is the response.
/// Evaluates overall conversation trajectory.
/// </summary>
public static IConversationSplitter Full { get; } = new FullSplitter();
private sealed class LastTurnSplitter : IConversationSplitter
{
public (IReadOnlyList<ChatMessage>, IReadOnlyList<ChatMessage>) Split(
IReadOnlyList<ChatMessage> conversation)
{
int lastUserIdx = -1;
for (int i = 0; i < conversation.Count; i++)
{
if (conversation[i].Role == ChatRole.User)
{
lastUserIdx = i;
}
}
if (lastUserIdx >= 0)
{
return (
conversation.Take(lastUserIdx + 1).ToList(),
conversation.Skip(lastUserIdx + 1).ToList());
}
return (new List<ChatMessage>(), conversation.ToList());
}
}
private sealed class FullSplitter : IConversationSplitter
{
public (IReadOnlyList<ChatMessage>, IReadOnlyList<ChatMessage>) Split(
IReadOnlyList<ChatMessage> conversation)
{
int firstUserIdx = -1;
for (int i = 0; i < conversation.Count; i++)
{
if (conversation[i].Role == ChatRole.User)
{
firstUserIdx = i;
break;
}
}
if (firstUserIdx >= 0)
{
return (
conversation.Take(firstUserIdx + 1).ToList(),
conversation.Skip(firstUserIdx + 1).ToList());
}
return (new List<ChatMessage>(), conversation.ToList());
}
}
}
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI.Evaluation;
namespace Microsoft.Agents.AI;
/// <summary>
/// Evaluator that runs check functions locally without API calls.
/// </summary>
public sealed class LocalEvaluator : IAgentEvaluator
{
private readonly EvalCheck[] _checks;
/// <summary>
/// Initializes a new instance of the <see cref="LocalEvaluator"/> class.
/// </summary>
/// <param name="checks">The check functions to run on each item.</param>
public LocalEvaluator(params EvalCheck[] checks)
{
this._checks = checks;
}
/// <inheritdoc />
public string Name => "LocalEvaluator";
/// <inheritdoc />
public Task<AgentEvaluationResults> EvaluateAsync(
IReadOnlyList<EvalItem> items,
string evalName = "Local Eval",
CancellationToken cancellationToken = default)
{
var results = new List<EvaluationResult>(items.Count);
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
var evalResult = new EvaluationResult();
foreach (var check in this._checks)
{
var EvalCheckResult = check(item);
evalResult.Metrics[EvalCheckResult.CheckName] = new BooleanMetric(
EvalCheckResult.CheckName,
EvalCheckResult.Passed,
reason: EvalCheckResult.Reason)
{
Interpretation = new EvaluationMetricInterpretation
{
Rating = EvalCheckResult.Passed
? EvaluationRating.Good
: EvaluationRating.Unacceptable,
Failed = !EvalCheckResult.Passed,
},
};
}
results.Add(evalResult);
}
return Task.FromResult(new AgentEvaluationResults(this.Name, results, inputItems: items));
}
}
@@ -0,0 +1,63 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Evaluation;
namespace Microsoft.Agents.AI;
/// <summary>
/// Adapter that wraps an MEAI <see cref="IEvaluator"/> into an <see cref="IAgentEvaluator"/>.
/// Runs the MEAI evaluator per-item and aggregates results.
/// </summary>
internal sealed class MeaiEvaluatorAdapter : IAgentEvaluator
{
private readonly IEvaluator _evaluator;
private readonly ChatConfiguration _chatConfiguration;
/// <summary>
/// Initializes a new instance of the <see cref="MeaiEvaluatorAdapter"/> class.
/// </summary>
/// <param name="evaluator">The MEAI evaluator to wrap.</param>
/// <param name="chatConfiguration">Chat configuration for the evaluator (includes the judge model).</param>
public MeaiEvaluatorAdapter(IEvaluator evaluator, ChatConfiguration chatConfiguration)
{
this._evaluator = evaluator;
this._chatConfiguration = chatConfiguration;
}
/// <inheritdoc />
public string Name => this._evaluator.GetType().Name;
/// <inheritdoc />
public async Task<AgentEvaluationResults> EvaluateAsync(
IReadOnlyList<EvalItem> items,
string evalName = "MEAI Eval",
CancellationToken cancellationToken = default)
{
var results = new List<EvaluationResult>(items.Count);
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
var (queryMessages, _) = item.Split();
var messages = queryMessages.ToList();
var chatResponse = item.RawResponse
?? new ChatResponse(new ChatMessage(ChatRole.Assistant, item.Response));
var result = await this._evaluator.EvaluateAsync(
messages,
chatResponse,
this._chatConfiguration,
cancellationToken: cancellationToken).ConfigureAwait(false);
results.Add(result);
}
return new AgentEvaluationResults(this.Name, results, inputItems: items);
}
}
@@ -32,6 +32,14 @@
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
<!-- Evaluation support requires net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<PackageReference Include="Microsoft.Extensions.AI.Evaluation" />
</ItemGroup>
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="Evaluation\**\*.cs" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework</Title>