mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature-harness
This commit is contained in:
@@ -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<Projects.WriterAgent>("writer-agent");
|
||||
/// var editorAgent = builder.AddProject<Projects.EditorAgent>("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; }
|
||||
}
|
||||
+25
@@ -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();
|
||||
}
|
||||
+50
@@ -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<string, object></c> payloads
|
||||
/// with compile-time–safe 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)})");
|
||||
|
||||
+21
-11
@@ -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)
|
||||
|
||||
+4
-2
@@ -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;
|
||||
|
||||
+7
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -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>
|
||||
|
||||
+23
@@ -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 >
|
||||
/// <see cref="Splitter"/> > <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>
|
||||
|
||||
Reference in New Issue
Block a user