chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Aspire.Hosting.AgentFramework;
/// <summary>
/// Describes an AI agent exposed by an agent service backend, used for entity discovery in DevUI.
/// </summary>
/// <remarks>
/// <para>
/// When added via <see cref="AgentFrameworkBuilderExtensions.WithAgentService{TSource}"/>,
/// agent metadata is declared at the AppHost level so that the DevUI aggregator can build the
/// entity listing without querying each backend's <c>/v1/entities</c> endpoint.
/// </para>
/// <para>
/// Agent services only need to expose the standard OpenAI Responses and Conversations API endpoints
/// (<c>MapOpenAIResponses</c> and <c>MapOpenAIConversations</c>), not a custom discovery endpoint.
/// </para>
/// </remarks>
/// <param name="Id">The unique identifier for the agent, typically matching the name passed to <c>AddAIAgent</c>.</param>
/// <param name="Description">A short description of the agent's capabilities.</param>
public record AgentEntityInfo(string Id, string? Description = null)
{
/// <summary>
/// Gets the display name for the agent. Defaults to <see cref="Id"/> if not specified.
/// </summary>
public string Name { get; init; } = Id;
/// <summary>
/// Gets the entity type. Defaults to <c>"agent"</c>.
/// </summary>
public string Type { get; init; } = "agent";
/// <summary>
/// Gets the framework identifier. Defaults to <c>"agent_framework"</c>.
/// </summary>
public string Framework { get; init; } = "agent_framework";
}
@@ -0,0 +1,185 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Aspire.Hosting.AgentFramework;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting;
/// <summary>
/// Provides extension methods for adding Agent Framework DevUI resources to the application model.
/// </summary>
public static class AgentFrameworkBuilderExtensions
{
/// <summary>
/// Adds a DevUI resource for testing AI agents in a distributed application.
/// </summary>
/// <remarks>
/// <para>
/// DevUI is a web-based interface for testing and debugging AI agents using the OpenAI Responses protocol.
/// When configured with <see cref="WithAgentService{TSource}"/>, it aggregates agents from multiple backend services
/// and provides a unified testing interface.
/// </para>
/// <para>
/// The aggregator runs as an in-process reverse proxy within the AppHost, requiring no external container image.
/// It serves the DevUI frontend from embedded resources in Microsoft.Agents.AI.DevUI when available, and
/// falls back to proxying from the first configured backend. It aggregates entity listings from all backends.
/// </para>
/// <para>
/// This resource is excluded from the deployment manifest as it is intended for development use only.
/// </para>
/// </remarks>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <param name="name">The name to give the resource.</param>
/// <param name="port">The host port for the DevUI web interface. If not specified, a random port will be assigned.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <example>
/// <code>
/// var devui = builder.AddDevUI("devui")
/// .WithAgentService(dotnetAgent)
/// .WithAgentService(pythonAgent);
/// </code>
/// </example>
public static IResourceBuilder<DevUIResource> AddDevUI(
this IDistributedApplicationBuilder builder,
string name,
int? port = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);
var resource = new DevUIResource(name, port);
var resourceBuilder = builder.AddResource(resource)
.ExcludeFromManifest(); // DevUI is a dev-only tool
// Initialize the in-process aggregator when the resource is initialized by the orchestrator
builder.Eventing.Subscribe<InitializeResourceEvent>(resource, async (e, ct) =>
{
var logger = e.Logger;
var aggregator = new DevUIAggregatorHostedService(resource, e.Services.GetRequiredService<ILoggerFactory>().CreateLogger<DevUIAggregatorHostedService>());
try
{
// Wait for dependencies (e.g. agent service backends) before starting.
// Custom resources must manually publish BeforeResourceStartedEvent to trigger
// the orchestrator's WaitFor mechanism.
await e.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, e.Services), ct).ConfigureAwait(false);
await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = KnownResourceStates.Starting
}).ConfigureAwait(false);
await aggregator.StartAsync(ct).ConfigureAwait(false);
// Allocate the endpoint so the URL appears in the Aspire dashboard
var endpointAnnotation = resource.Annotations
.OfType<EndpointAnnotation>()
.First(ea => ea.Name == DevUIResource.PrimaryEndpointName);
endpointAnnotation.AllocatedEndpoint = new AllocatedEndpoint(
endpointAnnotation, "localhost", aggregator.AllocatedPort);
var devuiUrl = $"http://localhost:{aggregator.AllocatedPort}/devui/";
await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = KnownResourceStates.Running,
Urls = [new UrlSnapshot("DevUI", devuiUrl, IsInternal: false)]
}).ConfigureAwait(false);
// Shut down the aggregator when the app stops
var lifetime = e.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() =>
{
e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = KnownResourceStates.Finished
}).GetAwaiter().GetResult();
aggregator.StopAsync(CancellationToken.None).GetAwaiter().GetResult();
aggregator.DisposeAsync().AsTask().GetAwaiter().GetResult();
});
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to start DevUI aggregator");
await aggregator.DisposeAsync().ConfigureAwait(false);
await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = KnownResourceStates.FailedToStart
}).ConfigureAwait(false);
}
});
return resourceBuilder;
}
/// <summary>
/// Configures DevUI to connect to an agent service backend.
/// </summary>
/// <remarks>
/// <para>
/// Each agent service should expose the OpenAI Responses and Conversations API endpoints
/// (via <c>MapOpenAIResponses</c> and <c>MapOpenAIConversations</c>).
/// </para>
/// <para>
/// When <paramref name="agents"/> is provided, the aggregator builds the entity listing from
/// these declarations without querying the backend. When not provided, a single agent named
/// after the service resource is assumed. Agent services don't need a <c>/v1/entities</c> endpoint.
/// </para>
/// </remarks>
/// <typeparam name="TSource">The type of the agent service resource.</typeparam>
/// <param name="builder">The DevUI resource builder.</param>
/// <param name="agentService">The agent service resource to connect to.</param>
/// <param name="agents">
/// Optional list of agents declared by this backend. When provided, the aggregator uses these
/// declarations directly. When not provided, defaults to a single agent named after the
/// <paramref name="agentService"/> resource. The backend doesn't need to expose a
/// <c>/v1/entities</c> endpoint in either case.
/// </param>
/// <param name="entityIdPrefix">
/// An optional prefix to add to entity IDs from this backend.
/// If not specified, the resource name will be used as the prefix.
/// </param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <example>
/// <code>
/// var writerAgent = builder.AddProject&lt;Projects.WriterAgent&gt;("writer-agent");
/// var editorAgent = builder.AddProject&lt;Projects.EditorAgent&gt;("editor-agent");
///
/// builder.AddDevUI("devui")
/// .WithAgentService(writerAgent, agents: [new("writer", "Writes short stories")])
/// .WithAgentService(editorAgent, agents: [new("editor", "Edits and formats stories")])
/// .WaitFor(writerAgent)
/// .WaitFor(editorAgent);
/// </code>
/// </example>
public static IResourceBuilder<DevUIResource> WithAgentService<TSource>(
this IResourceBuilder<DevUIResource> builder,
IResourceBuilder<TSource> agentService,
IReadOnlyList<AgentEntityInfo>? agents = null,
string? entityIdPrefix = null)
where TSource : IResourceWithEndpoints
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(agentService);
// Default to a single agent named after the service resource
agents ??= [new AgentEntityInfo(agentService.Resource.Name)];
builder.WithAnnotation(new AgentServiceAnnotation(agentService.Resource, entityIdPrefix, agents));
builder.WithRelationship(agentService.Resource, "agent-backend");
return builder;
}
}
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Aspire.Hosting.AgentFramework;
namespace Aspire.Hosting.ApplicationModel;
/// <summary>
/// An annotation that tracks an agent service backend referenced by a DevUI resource.
/// </summary>
/// <remarks>
/// This annotation is used to configure DevUI to aggregate entities from multiple
/// agent service backends. Each annotation represents one backend that DevUI should
/// connect to for entity discovery and request routing.
/// </remarks>
public class AgentServiceAnnotation : IResourceAnnotation
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentServiceAnnotation"/> class.
/// </summary>
/// <param name="agentService">The agent service resource.</param>
/// <param name="entityIdPrefix">
/// An optional prefix to add to entity IDs from this backend to avoid conflicts.
/// If not specified, the resource name will be used as the prefix.
/// </param>
/// <param name="agents">
/// Optional list of agents declared by this backend. When provided, the aggregator builds the entity
/// listing directly from these declarations instead of querying the backend's <c>/v1/entities</c> endpoint.
/// </param>
public AgentServiceAnnotation(IResource agentService, string? entityIdPrefix = null, IReadOnlyList<AgentEntityInfo>? agents = null)
{
ArgumentNullException.ThrowIfNull(agentService);
this.AgentService = agentService;
this.EntityIdPrefix = entityIdPrefix;
this.Agents = agents ?? [];
}
/// <summary>
/// Gets the agent service resource that exposes AI agents.
/// </summary>
public IResource AgentService { get; }
/// <summary>
/// Gets the prefix to use for entity IDs from this backend.
/// </summary>
/// <remarks>
/// When <c>null</c>, the resource name will be used as the prefix.
/// Entity IDs will be formatted as "{prefix}/{entityId}" to ensure uniqueness
/// across multiple agent backends.
/// </remarks>
public string? EntityIdPrefix { get; }
/// <summary>
/// Gets the list of agents declared by this backend.
/// </summary>
/// <remarks>
/// When non-empty, the DevUI aggregator uses these declarations to build the entity listing
/// without querying the backend. When empty, the aggregator falls back to calling
/// <c>GET /v1/entities</c> on the backend for discovery.
/// </remarks>
public IReadOnlyList<AgentEntityInfo> Agents { get; }
}
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<VersionSuffix>preview</VersionSuffix>
<!-- 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>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework DevUI for Aspire</Title>
<PackageTags>aspire integration hosting agent-framework devui ai agents</PackageTags>
<Description>Microsoft Agent Framework DevUI support for Aspire.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Aspire.Hosting.AgentFramework.DevUI.UnitTests" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>
</Project>
@@ -0,0 +1,828 @@
// 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 static readonly string[] s_preferredBackendEndpointNames = ["https", "http"];
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>
internal 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;
var endpointUrl = this.ResolveBackendUrl(rwe, prefix);
if (endpointUrl is not null)
{
result[prefix] = endpointUrl;
}
}
return result;
}
private string? ResolveBackendUrl(IResourceWithEndpoints resource, string prefix)
{
foreach (var endpointName in s_preferredBackendEndpointNames)
{
try
{
var endpoint = resource.GetEndpoint(endpointName);
if (endpoint.IsAllocated)
{
return endpoint.Url;
}
}
catch (InvalidOperationException ex)
{
this._logger.LogDebug(
ex,
"Backend '{Prefix}' {EndpointName} endpoint not yet available",
prefix,
endpointName);
}
}
return null;
}
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 = ValidateProxyTarget(backendUrl, path);
if (targetUri is null)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync("Invalid proxy target.", context.RequestAborted).ConfigureAwait(false);
return;
}
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);
}
/// <summary>
/// Validates that constructing a proxy target URI from <paramref name="backendUrl"/> and
/// <paramref name="path"/> does not redirect the request to an unintended host.
/// Returns the validated <see cref="Uri"/> if safe, or <c>null</c> if the target is invalid.
/// </summary>
internal static Uri? ValidateProxyTarget(string backendUrl, string path)
{
if (!Uri.TryCreate(backendUrl, UriKind.Absolute, out var baseUri) ||
!Uri.TryCreate(baseUri, path, out var targetUri))
{
return null;
}
if (!string.Equals(targetUri.Host, baseUri.Host, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(targetUri.Scheme, baseUri.Scheme, StringComparison.OrdinalIgnoreCase) ||
targetUri.Port != baseUri.Port)
{
return null;
}
return targetUri;
}
}
@@ -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
+9
View File
@@ -0,0 +1,9 @@
<Project>
<Import Project="../Directory.Build.props" />
<ItemGroup>
<PackageReference Include="ReferenceTrimmer" PrivateAssets="all" IncludeAssets="build;analyzers;buildTransitive" />
</ItemGroup>
</Project>
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
namespace System.Runtime.CompilerServices;
/// <summary>
/// Tags parameter that should be filled with specific caller name.
/// </summary>
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
[ExcludeFromCodeCoverage]
internal sealed class CallerArgumentExpressionAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="CallerArgumentExpressionAttribute"/> class.
/// </summary>
/// <param name="parameterName">Function parameter to take the name from.</param>
public CallerArgumentExpressionAttribute(string parameterName)
{
this.ParameterName = parameterName;
}
/// <summary>
/// Gets name of the function parameter that name should be taken from.
/// </summary>
public string ParameterName { get; }
}
@@ -0,0 +1,9 @@
# CallerAttributes
To use this source in your project, add the following to your `.csproj` file:
```xml
<PropertyGroup>
<InjectCallerAttributesOnLegacy>true</InjectCallerAttributesOnLegacy>
</PropertyGroup>
```
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable SA1623 // Property summary documentation should match accessors
namespace System.Runtime.CompilerServices;
/// <summary>
/// Indicates that compiler support for a particular feature is required for the location where this attribute is applied.
/// </summary>
[AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)]
internal sealed class CompilerFeatureRequiredAttribute : Attribute
{
public CompilerFeatureRequiredAttribute(string featureName)
{
this.FeatureName = featureName;
}
/// <summary>
/// The name of the compiler feature.
/// </summary>
public string FeatureName { get; }
/// <summary>
/// If true, the compiler can choose to allow access to the location where this attribute is applied if it does not understand <see cref="FeatureName"/>.
/// </summary>
public bool IsOptional { get; init; }
/// <summary>
/// The <see cref="FeatureName"/> used for the ref structs C# feature.
/// </summary>
public const string RefStructs = nameof(RefStructs);
/// <summary>
/// The <see cref="FeatureName"/> used for the required members C# feature.
/// </summary>
public const string RequiredMembers = nameof(RequiredMembers);
}
@@ -0,0 +1,9 @@
Enables use of C# required members on older frameworks.
To use this source in your project, add the following to your `.csproj` file:
```xml
<PropertyGroup>
<InjectCompilerFeatureRequiredOnLegacy>true</InjectCompilerFeatureRequiredOnLegacy>
</PropertyGroup>
```
@@ -0,0 +1,162 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable CA1019, RCS1251, IDE0300
namespace System.Diagnostics.CodeAnalysis;
#if !NETCOREAPP3_1_OR_GREATER
/// <summary>Specifies that null is allowed as an input even if the corresponding type disallows it.</summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)]
[ExcludeFromCodeCoverage]
internal sealed class AllowNullAttribute : Attribute
{
}
/// <summary>Specifies that null is disallowed as an input even if the corresponding type allows it.</summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)]
[ExcludeFromCodeCoverage]
internal sealed class DisallowNullAttribute : Attribute
{
}
/// <summary>Specifies that an output may be null even if the corresponding type disallows it.</summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)]
[ExcludeFromCodeCoverage]
internal sealed class MaybeNullAttribute : Attribute
{
}
/// <summary>Specifies that an output will not be null even if the corresponding type allows it. Specifies that an input argument was not null when the call returns.</summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)]
[ExcludeFromCodeCoverage]
internal sealed class NotNullAttribute : Attribute
{
}
/// <summary>Specifies that when a method returns <see cref="ReturnValue"/>, the parameter may be null even if the corresponding type disallows it.</summary>
[AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
[ExcludeFromCodeCoverage]
internal sealed class MaybeNullWhenAttribute : Attribute
{
/// <summary>Initializes the attribute with the specified return value condition.</summary>
/// <param name="returnValue">
/// The return value condition. If the method returns this value, the associated parameter may be <see langword="null" />.
/// </param>
public MaybeNullWhenAttribute(bool returnValue) => this.ReturnValue = returnValue;
/// <summary>Gets the return value condition.</summary>
public bool ReturnValue { get; }
}
/// <summary>Specifies that when a method returns <see cref="ReturnValue"/>, the parameter will not be null even if the corresponding type allows it.</summary>
[AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
[ExcludeFromCodeCoverage]
internal sealed class NotNullWhenAttribute : Attribute
{
/// <summary>Initializes the attribute with the specified return value condition.</summary>
/// <param name="returnValue">
/// The return value condition. If the method returns this value, the associated parameter will not be <see langword="null" />.
/// </param>
public NotNullWhenAttribute(bool returnValue) => this.ReturnValue = returnValue;
/// <summary>Gets the return value condition.</summary>
public bool ReturnValue { get; }
}
/// <summary>Specifies that the output will be non-null if the named parameter is non-null.</summary>
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, AllowMultiple = true, Inherited = false)]
[ExcludeFromCodeCoverage]
internal sealed class NotNullIfNotNullAttribute : Attribute
{
/// <summary>Initializes the attribute with the associated parameter name.</summary>
/// <param name="parameterName">
/// The associated parameter name. The output will be non-null if the argument to the parameter specified is non-null.
/// </param>
public NotNullIfNotNullAttribute(string parameterName) => this.ParameterName = parameterName;
/// <summary>Gets the associated parameter name.</summary>
public string ParameterName { get; }
}
/// <summary>Applied to a method that will never return under any circumstance.</summary>
[AttributeUsage(AttributeTargets.Method, Inherited = false)]
[ExcludeFromCodeCoverage]
internal sealed class DoesNotReturnAttribute : Attribute
{
}
/// <summary>Specifies that the method will not return if the associated Boolean parameter is passed the specified value.</summary>
[AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
[ExcludeFromCodeCoverage]
internal sealed class DoesNotReturnIfAttribute : Attribute
{
/// <summary>Initializes the attribute with the specified parameter value.</summary>
/// <param name="parameterValue">
/// The condition parameter value. Code after the method will be considered unreachable by diagnostics if the argument to
/// the associated parameter matches this value.
/// </param>
public DoesNotReturnIfAttribute(bool parameterValue) => this.ParameterValue = parameterValue;
/// <summary>Gets the condition parameter value.</summary>
public bool ParameterValue { get; }
}
#endif
/// <summary>Specifies that the method or property will ensure that the listed field and property members have not-null values.</summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)]
[ExcludeFromCodeCoverage]
internal sealed class MemberNotNullAttribute : Attribute
{
/// <summary>Initializes the attribute with a field or property member.</summary>
/// <param name="member">
/// The field or property member that is promised to be not-null.
/// </param>
public MemberNotNullAttribute(string member) => this.Members = new[] { member };
/// <summary>Initializes the attribute with the list of field and property members.</summary>
/// <param name="members">
/// The list of field and property members that are promised to be not-null.
/// </param>
public MemberNotNullAttribute(params string[] members) => this.Members = members;
/// <summary>Gets field or property member names.</summary>
public string[] Members { get; }
}
/// <summary>Specifies that the method or property will ensure that the listed field and property members have not-null values when returning with the specified return value condition.</summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)]
[ExcludeFromCodeCoverage]
internal sealed class MemberNotNullWhenAttribute : Attribute
{
/// <summary>Initializes the attribute with the specified return value condition and a field or property member.</summary>
/// <param name="returnValue">
/// The return value condition. If the method returns this value, the associated parameter will not be <see langword="null" />.
/// </param>
/// <param name="member">
/// The field or property member that is promised to be not-null.
/// </param>
public MemberNotNullWhenAttribute(bool returnValue, string member)
{
this.ReturnValue = returnValue;
this.Members = [member];
}
/// <summary>Initializes the attribute with the specified return value condition and list of field and property members.</summary>
/// <param name="returnValue">
/// The return value condition. If the method returns this value, the associated parameter will not be <see langword="null" />.
/// </param>
/// <param name="members">
/// The list of field and property members that are promised to be not-null.
/// </param>
public MemberNotNullWhenAttribute(bool returnValue, params string[] members)
{
this.ReturnValue = returnValue;
this.Members = members;
}
/// <summary>Gets the return value condition.</summary>
public bool ReturnValue { get; }
/// <summary>Gets field or property member names.</summary>
public string[] Members { get; }
}
@@ -0,0 +1,9 @@
# DiagnosticAttributes
To use this source in your project, add the following to your `.csproj` file:
```xml
<PropertyGroup>
<InjectDiagnosticAttributesOnLegacy>true</InjectDiagnosticAttributesOnLegacy>
</PropertyGroup>
```
@@ -0,0 +1,9 @@
# DiagnosticClasses
To use this source in your project, add the following to your `.csproj` file:
```xml
<PropertyGroup>
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
</PropertyGroup>
```
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
// Polyfill for using UnreachableException with .NET Standard 2.0
namespace System.Diagnostics;
#pragma warning disable CA1064 // Exceptions should be public
#pragma warning disable CA1812 // Internal class that is (sometimes) never instantiated.
/// <summary>
/// Exception thrown when the program executes an instruction that was thought to be unreachable.
/// </summary>
internal sealed class UnreachableException : Exception
{
private const string MessageText = "The program executed an instruction that was thought to be unreachable.";
/// <summary>
/// Initializes a new instance of the <see cref="UnreachableException"/> class with the default error message.
/// </summary>
public UnreachableException()
: base(MessageText)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="UnreachableException"/>
/// class with a specified error message.
/// </summary>
/// <param name="message">The error message that explains the reason for the exception.</param>
public UnreachableException(string? message)
: base(message ?? MessageText)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="UnreachableException"/>
/// class with a specified error message and a reference to the inner exception that is the cause of
/// this exception.
/// </summary>
/// <param name="message">The error message that explains the reason for the exception.</param>
/// <param name="innerException">The exception that is the cause of the current exception.</param>
public UnreachableException(string? message, Exception? innerException)
: base(message ?? MessageText, innerException)
{
}
}
@@ -0,0 +1,53 @@
// Copyright (c) Microsoft. All rights reserved.
#if !NET8_0_OR_GREATER
namespace System.Diagnostics.CodeAnalysis;
/// <summary>
/// Indicates that an API element is experimental and subject to change without notice.
/// </summary>
[ExcludeFromCodeCoverage]
[AttributeUsage(
AttributeTargets.Class |
AttributeTargets.Struct |
AttributeTargets.Enum |
AttributeTargets.Interface |
AttributeTargets.Delegate |
AttributeTargets.Method |
AttributeTargets.Constructor |
AttributeTargets.Property |
AttributeTargets.Field |
AttributeTargets.Event |
AttributeTargets.Assembly)]
internal sealed class ExperimentalAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="ExperimentalAttribute"/> class.
/// </summary>
/// <param name="diagnosticId">Human readable explanation for marking experimental API.</param>
public ExperimentalAttribute(string diagnosticId)
{
this.DiagnosticId = diagnosticId;
}
/// <summary>
/// Gets the ID that the compiler will use when reporting a use of the API the attribute applies to.
/// </summary>
/// <value>The unique diagnostic ID.</value>
/// <remarks>
/// The diagnostic ID is shown in build output for warnings and errors.
/// <para>This property represents the unique ID that can be used to suppress the warnings or errors, if needed.</para>
/// </remarks>
public string DiagnosticId { get; }
/// <summary>
/// Gets or sets the URL for corresponding documentation.
/// The API accepts a format string instead of an actual URL, creating a generic URL that includes the diagnostic ID.
/// </summary>
/// <value>The format string that represents a URL to corresponding documentation.</value>
/// <remarks>An example format string is <c>https://contoso.com/obsoletion-warnings/{0}</c>.</remarks>
public string? UrlFormat { get; set; }
}
#endif
@@ -0,0 +1,9 @@
# ExperimentalAttribute
To use this source in your project, add the following to your `.csproj` file:
```xml
<PropertyGroup>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
</PropertyGroup>
```
@@ -0,0 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
/* This enables support for C# 9/10 records on older frameworks */
namespace System.Runtime.CompilerServices;
internal static class IsExternalInit;
@@ -0,0 +1,11 @@
# IsExternalInit
Enables use of C# record types on older frameworks.
To use this source in your project, add the following to your `.csproj` file:
```xml
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
</PropertyGroup>
```
+8
View File
@@ -0,0 +1,8 @@
# About this Folder
This folder contains a bunch of sources copied from newer versions of .NET which we pull in to
our sources as necessary. This enables us to compile source code that depends on these newer
features from .NET even when targeting older frameworks.
Please see the `eng/MSBuild/LegacySupport.props` file for the set of project properties that control importing
these source files into your project.
@@ -0,0 +1,9 @@
Enables use of C# required members on older frameworks.
To use this source in your project, add the following to your `.csproj` file:
```xml
<PropertyGroup>
<InjectRequiredMemberOnLegacy>true</InjectRequiredMemberOnLegacy>
</PropertyGroup>
```
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
namespace System.Runtime.CompilerServices;
/// <summary>Specifies that a type has required members or that a member is required.</summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
[EditorBrowsable(EditorBrowsableState.Never)]
internal sealed class RequiredMemberAttribute : Attribute;
@@ -0,0 +1,95 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable RCS1157 // Composite enum value contains undefined flag
namespace System.Diagnostics.CodeAnalysis;
/// <summary>
/// Specifies the types of members that are dynamically accessed.
///
/// This enumeration has a <see cref="FlagsAttribute"/> attribute that allows a
/// bitwise combination of its member values.
/// </summary>
[Flags]
internal enum DynamicallyAccessedMemberTypes
{
/// <summary>
/// Specifies no members.
/// </summary>
None = 0,
/// <summary>
/// Specifies the default, parameterless public constructor.
/// </summary>
PublicParameterlessConstructor = 0x0001,
/// <summary>
/// Specifies all public constructors.
/// </summary>
PublicConstructors = 0x0002 | PublicParameterlessConstructor,
/// <summary>
/// Specifies all non-public constructors.
/// </summary>
NonPublicConstructors = 0x0004,
/// <summary>
/// Specifies all public methods.
/// </summary>
PublicMethods = 0x0008,
/// <summary>
/// Specifies all non-public methods.
/// </summary>
NonPublicMethods = 0x0010,
/// <summary>
/// Specifies all public fields.
/// </summary>
PublicFields = 0x0020,
/// <summary>
/// Specifies all non-public fields.
/// </summary>
NonPublicFields = 0x0040,
/// <summary>
/// Specifies all public nested types.
/// </summary>
PublicNestedTypes = 0x0080,
/// <summary>
/// Specifies all non-public nested types.
/// </summary>
NonPublicNestedTypes = 0x0100,
/// <summary>
/// Specifies all public properties.
/// </summary>
PublicProperties = 0x0200,
/// <summary>
/// Specifies all non-public properties.
/// </summary>
NonPublicProperties = 0x0400,
/// <summary>
/// Specifies all public events.
/// </summary>
PublicEvents = 0x0800,
/// <summary>
/// Specifies all non-public events.
/// </summary>
NonPublicEvents = 0x1000,
/// <summary>
/// Specifies all interfaces implemented by the type.
/// </summary>
Interfaces = 0x2000,
/// <summary>
/// Specifies all members.
/// </summary>
All = ~None
}
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft. All rights reserved.
namespace System.Diagnostics.CodeAnalysis;
/// <summary>
/// Indicates that certain members on a specified <see cref="Type"/> are accessed dynamically,
/// for example through <see cref="Reflection"/>.
/// </summary>
/// <remarks>
/// This allows tools to understand which members are being accessed during the execution
/// of a program.
///
/// This attribute is valid on members whose type is <see cref="Type"/> or <see cref="string"/>.
///
/// When this attribute is applied to a location of type <see cref="string"/>, the assumption is
/// that the string represents a fully qualified type name.
///
/// When this attribute is applied to a class, interface, or struct, the members specified
/// can be accessed dynamically on <see cref="Type"/> instances returned from calling
/// <see cref="object.GetType"/> on instances of that class, interface, or struct.
///
/// If the attribute is applied to a method it's treated as a special case and it implies
/// the attribute should be applied to the "this" parameter of the method. As such the attribute
/// should only be used on instance methods of types assignable to System.Type (or string, but no methods
/// will use it there).
/// </remarks>
[AttributeUsage(
AttributeTargets.Field | AttributeTargets.ReturnValue | AttributeTargets.GenericParameter |
AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Method |
AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct,
Inherited = false)]
[ExcludeFromCodeCoverage]
internal sealed class DynamicallyAccessedMembersAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="DynamicallyAccessedMembersAttribute"/> class
/// with the specified member types.
/// </summary>
/// <param name="memberTypes">The types of members dynamically accessed.</param>
public DynamicallyAccessedMembersAttribute(DynamicallyAccessedMemberTypes memberTypes)
{
this.MemberTypes = memberTypes;
}
/// <summary>
/// Gets the <see cref="DynamicallyAccessedMemberTypes"/> which specifies the type
/// of members dynamically accessed.
/// </summary>
public DynamicallyAccessedMemberTypes MemberTypes { get; }
}
@@ -0,0 +1,9 @@
# TrimAttributes
To use this source in your project, add the following to your `.csproj` file:
```xml
<PropertyGroup>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
```
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
namespace System.Diagnostics.CodeAnalysis;
/// <summary>
/// Indicates that the specified method requires the ability to generate new code at runtime,
/// for example through <see cref="Reflection"/>.
/// </summary>
/// <remarks>
/// This allows tools to understand which methods are unsafe to call when compiling ahead of time.
/// </remarks>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Constructor | AttributeTargets.Class, Inherited = false)]
[ExcludeFromCodeCoverage]
internal sealed class RequiresDynamicCodeAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="RequiresDynamicCodeAttribute"/> class
/// with the specified message.
/// </summary>
/// <param name="message">
/// A message that contains information about the usage of dynamic code.
/// </param>
public RequiresDynamicCodeAttribute(string message)
{
this.Message = message;
}
/// <summary>
/// Gets a message that contains information about the usage of dynamic code.
/// </summary>
public string Message { get; }
/// <summary>
/// Gets or sets an optional URL that contains more information about the method,
/// why it requires dynamic code, and what options a consumer has to deal with it.
/// </summary>
public string? Url { get; set; }
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
namespace System.Diagnostics.CodeAnalysis;
/// <summary>
/// Indicates that the specified method requires dynamic access to code that is not referenced
/// statically, for example through <see cref="Reflection"/>.
/// </summary>
/// <remarks>
/// This allows tools to understand which methods are unsafe to call when removing unreferenced
/// code from an application.
/// </remarks>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Constructor | AttributeTargets.Class, Inherited = false)]
[ExcludeFromCodeCoverage]
internal sealed class RequiresUnreferencedCodeAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="RequiresUnreferencedCodeAttribute"/> class
/// with the specified message.
/// </summary>
/// <param name="message">
/// A message that contains information about the usage of unreferenced code.
/// </param>
public RequiresUnreferencedCodeAttribute(string message)
{
this.Message = message;
}
/// <summary>
/// Gets a message that contains information about the usage of unreferenced code.
/// </summary>
public string Message { get; }
/// <summary>
/// Gets or sets an optional URL that contains more information about the method,
/// why it requires unreferenced code, and what options a consumer has to deal with it.
/// </summary>
public string? Url { get; set; }
}
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft. All rights reserved.
namespace System.Diagnostics.CodeAnalysis;
/// <summary>
/// /// Suppresses reporting of a specific rule violation, allowing multiple suppressions on a
/// single code artifact.
/// </summary>
/// <remarks>
/// <see cref="UnconditionalSuppressMessageAttribute"/> is different than
/// <see cref="SuppressMessageAttribute"/> in that it doesn't have a
/// <see cref="ConditionalAttribute"/>. So it is always preserved in the compiled assembly.
/// </remarks>
[AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = true)]
[ExcludeFromCodeCoverage]
internal sealed class UnconditionalSuppressMessageAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="UnconditionalSuppressMessageAttribute"/>
/// class, specifying the category of the tool and the identifier for an analysis rule.
/// </summary>
/// <param name="category">The category for the attribute.</param>
/// <param name="checkId">The identifier of the analysis rule the attribute applies to.</param>
public UnconditionalSuppressMessageAttribute(string category, string checkId)
{
this.Category = category;
this.CheckId = checkId;
}
/// <summary>
/// Gets the category identifying the classification of the attribute.
/// </summary>
/// <remarks>
/// The <see cref="Category"/> property describes the tool or tool analysis category
/// for which a message suppression attribute applies.
/// </remarks>
public string Category { get; }
/// <summary>
/// Gets the identifier of the analysis tool rule to be suppressed.
/// </summary>
/// <remarks>
/// Concatenated together, the <see cref="Category"/> and <see cref="CheckId"/>
/// properties form a unique check identifier.
/// </remarks>
public string CheckId { get; }
/// <summary>
/// Gets or sets the scope of the code that is relevant for the attribute.
/// </summary>
/// <remarks>
/// The Scope property is an optional argument that specifies the metadata scope for which
/// the attribute is relevant.
/// </remarks>
public string? Scope { get; set; }
/// <summary>
/// Gets or sets a fully qualified path that represents the target of the attribute.
/// </summary>
/// <remarks>
/// The <see cref="Target"/> property is an optional argument identifying the analysis target
/// of the attribute. An example value is "System.IO.Stream.ctor():System.Void".
/// Because it is fully qualified, it can be long, particularly for targets such as parameters.
/// The analysis tool user interface should be capable of automatically formatting the parameter.
/// </remarks>
public string? Target { get; set; }
/// <summary>
/// Gets or sets an optional argument expanding on exclusion criteria.
/// </summary>
/// <remarks>
/// The <see cref="MessageId"/> property is an optional argument that specifies additional
/// exclusion where the literal metadata target is not sufficiently precise. For example,
/// the <see cref="UnconditionalSuppressMessageAttribute"/> cannot be applied within a method,
/// and it may be desirable to suppress a violation against a statement in the method that will
/// give a rule violation, but not against all statements in the method.
/// </remarks>
public string? MessageId { get; set; }
/// <summary>
/// Gets or sets the justification for suppressing the code analysis message.
/// </summary>
public string? Justification { get; set; }
}
@@ -0,0 +1,501 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.A2A;
/// <summary>
/// Represents an <see cref="AIAgent"/> that can interact with remote agents that are exposed via the A2A protocol
/// </summary>
/// <remarks>
/// This agent supports only messages as a response from A2A agents.
/// Support for tasks will be added later as part of the long-running
/// executions work.
/// </remarks>
public sealed class A2AAgent : AIAgent
{
private static readonly AIAgentMetadata s_agentMetadata = new("a2a");
private readonly IA2AClient _a2aClient;
private readonly A2AAgentOptions _agentOptions;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="A2AAgent"/> class.
/// </summary>
/// <param name="a2aClient">The A2A client to use for interacting with A2A agents.</param>
/// <param name="id">The unique identifier for the agent.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="description">The description of the agent.</param>
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
public A2AAgent(IA2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
: this(
a2aClient,
new A2AAgentOptions
{
Id = id,
Name = name,
Description = description
},
loggerFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="A2AAgent"/> class.
/// </summary>
/// <param name="a2aClient">The A2A client to use for interacting with A2A agents.</param>
/// <param name="options">
/// Configuration options that control the agent's identity, including its identifier, name, and description.
/// </param>
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
public A2AAgent(IA2AClient a2aClient, A2AAgentOptions options, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(a2aClient);
_ = Throw.IfNull(options);
this._a2aClient = a2aClient;
this._agentOptions = options.Clone();
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<A2AAgent>();
}
/// <inheritdoc/>
protected sealed override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> new(new A2AAgentSession());
/// <summary>
/// Get a new <see cref="AgentSession"/> instance using an existing context id, to continue that conversation.
/// </summary>
/// <param name="contextId">The context id to continue.</param>
/// <returns>A value task representing the asynchronous operation. The task result contains a new <see cref="AgentSession"/> instance.</returns>
public ValueTask<AgentSession> CreateSessionAsync(string contextId)
=> new(new A2AAgentSession() { ContextId = Throw.IfNullOrWhitespace(contextId) });
/// <summary>
/// Get a new <see cref="AgentSession"/> instance using an existing context id and task id, to resume that conversation from a specific task.
/// </summary>
/// <param name="contextId">The context id to continue.</param>
/// <param name="taskId">The task id to resume from.</param>
/// <returns>A value task representing the asynchronous operation. The task result contains a new <see cref="AgentSession"/> instance.</returns>
public ValueTask<AgentSession> CreateSessionAsync(string contextId, string taskId)
=> new(new A2AAgentSession() { ContextId = Throw.IfNullOrWhitespace(contextId), TaskId = Throw.IfNullOrWhitespace(taskId) });
/// <inheritdoc/>
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(session);
if (session is not A2AAgentSession typedSession)
{
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(A2AAgentSession)}' can be serialized by this agent.");
}
return new(typedSession.Serialize(jsonSerializerOptions));
}
/// <inheritdoc/>
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(A2AAgentSession.Deserialize(serializedState, jsonSerializerOptions));
/// <inheritdoc/>
protected override async Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
A2AAgentSession typedSession = await this.GetA2ASessionAsync(session, options, cancellationToken).ConfigureAwait(false);
this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name);
if (GetContinuationToken(inputMessages, options) is { } token)
{
AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = token.TaskId }, cancellationToken).ConfigureAwait(false);
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name);
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id, agentTask.Status.State);
return this.ConvertToAgentResponse(agentTask);
}
SendMessageRequest sendParams = new()
{
Message = CreateA2AMessage(typedSession, inputMessages),
Metadata = options?.AdditionalProperties?.ToA2AMetadata(),
Configuration = new SendMessageConfiguration { ReturnImmediately = options?.AllowBackgroundResponses is true }
};
SendMessageResponse a2aResponse = await this._a2aClient.SendMessageAsync(sendParams, cancellationToken).ConfigureAwait(false);
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name);
if (a2aResponse.PayloadCase == SendMessageResponseCase.Message)
{
var message = a2aResponse.Message!;
UpdateSession(typedSession, message.ContextId);
return this.ConvertToAgentResponse(message);
}
if (a2aResponse.PayloadCase == SendMessageResponseCase.Task)
{
var agentTask = a2aResponse.Task!;
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id, agentTask.Status.State);
return this.ConvertToAgentResponse(agentTask);
}
throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.PayloadCase}");
}
/// <inheritdoc/>
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
A2AAgentSession typedSession = await this.GetA2ASessionAsync(session, options, cancellationToken).ConfigureAwait(false);
this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name);
ConfiguredCancelableAsyncEnumerable<StreamResponse> streamEvents;
if (GetContinuationToken(inputMessages, options) is { } token)
{
streamEvents = this.SubscribeToTaskWithFallbackAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
}
else
{
SendMessageRequest sendParams = new()
{
Message = CreateA2AMessage(typedSession, inputMessages),
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
};
streamEvents = this._a2aClient.SendStreamingMessageAsync(sendParams, cancellationToken).ConfigureAwait(false);
}
this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name);
string? contextId = null;
string? taskId = null;
TaskState? taskState = null;
await foreach (var streamResponse in streamEvents)
{
switch (streamResponse.PayloadCase)
{
case StreamResponseCase.Message:
var message = streamResponse.Message!;
contextId = message.ContextId;
yield return this.ConvertToAgentResponseUpdate(message);
break;
case StreamResponseCase.Task:
var task = streamResponse.Task!;
contextId = task.ContextId;
taskId = task.Id;
taskState = task.Status.State;
yield return this.ConvertToAgentResponseUpdate(task);
break;
case StreamResponseCase.StatusUpdate:
var statusUpdate = streamResponse.StatusUpdate!;
contextId = statusUpdate.ContextId;
taskId = statusUpdate.TaskId;
taskState = statusUpdate.Status.State;
yield return this.ConvertToAgentResponseUpdate(statusUpdate);
break;
case StreamResponseCase.ArtifactUpdate:
var artifactUpdate = streamResponse.ArtifactUpdate!;
contextId = artifactUpdate.ContextId;
taskId = artifactUpdate.TaskId;
yield return this.ConvertToAgentResponseUpdate(artifactUpdate);
break;
default:
throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {streamResponse.PayloadCase}");
}
}
UpdateSession(typedSession, contextId, taskId, taskState);
}
/// <inheritdoc/>
protected override string? IdCore => this._agentOptions.Id;
/// <inheritdoc/>
public override string? Name => this._agentOptions.Name;
/// <inheritdoc/>
public override string? Description => this._agentOptions.Description;
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null)
=> base.GetService(serviceType, serviceKey)
?? (serviceType == typeof(IA2AClient) ? this._a2aClient
: serviceType == typeof(AIAgentMetadata) ? s_agentMetadata
: null);
private async ValueTask<A2AAgentSession> GetA2ASessionAsync(AgentSession? session, AgentRunOptions? options, CancellationToken cancellationToken)
{
// Aligning with other agent implementations that support background responses, where
// a session is required for background responses to prevent inconsistent experience
// for callers if they forget to provide the session for initial or follow-up runs.
if (options?.AllowBackgroundResponses is true && session is null)
{
throw new InvalidOperationException("A session must be provided when AllowBackgroundResponses is enabled.");
}
session ??= await this.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
if (session is not A2AAgentSession typedSession)
{
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(A2AAgentSession)}' can be used by this agent.");
}
return typedSession;
}
/// <summary>
/// Subscribes to task updates, falling back to <see cref="A2AClient.GetTaskAsync"/>
/// when the task has already reached a terminal state and the server responds with
/// <see cref="A2AErrorCode.UnsupportedOperation"/>.
/// </summary>
/// <remarks>
/// Per A2A spec §3.1.6, subscribing to a task in a terminal state (completed, failed,
/// canceled, or rejected) results in an <c>UnsupportedOperationError</c>.
/// See: <see href="https://a2a-protocol.org/latest/specification/#332-error-handling"/>.
/// </remarks>
private async IAsyncEnumerable<StreamResponse> SubscribeToTaskWithFallbackAsync(
string taskId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var subscribeStream = this._a2aClient.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = taskId }, cancellationToken);
var enumerator = subscribeStream.GetAsyncEnumerator(cancellationToken);
// yield return cannot appear inside a try block that has catch clauses,
// so we manually advance the enumerator within try/catch and yield outside it.
// The outer try/finally (no catch) is allowed to contain yield return in C#.
StreamResponse? fallbackResponse = null;
bool disposed = false;
try
{
while (true)
{
bool hasNext;
try
{
hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (A2AException ex) when (ex.ErrorCode == A2AErrorCode.UnsupportedOperation)
{
this._logger.LogA2ASubscribeToTaskFallback(this.Id, this.Name, taskId, ex.Message);
// Dispose the enumerator before the fallback call to release the HTTP/SSE connection.
await enumerator.DisposeAsync().ConfigureAwait(false);
disposed = true;
AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = taskId }, cancellationToken).ConfigureAwait(false);
fallbackResponse = new StreamResponse { Task = agentTask };
break;
}
if (!hasNext)
{
break;
}
yield return enumerator.Current;
}
if (fallbackResponse is not null)
{
yield return fallbackResponse;
}
}
finally
{
if (!disposed)
{
await enumerator.DisposeAsync().ConfigureAwait(false);
}
}
}
private static void UpdateSession(A2AAgentSession? session, string? contextId, string? taskId = null, TaskState? taskState = null)
{
if (session is null)
{
return;
}
// Surface cases where the A2A agent responds with a response that
// has a different context Id than the session's conversation Id.
if (session.ContextId is not null && contextId is not null && session.ContextId != contextId)
{
throw new InvalidOperationException(
$"The {nameof(contextId)} returned from the A2A agent is different from the conversation Id of the provided {nameof(AgentSession)}.");
}
// Assign a server-generated context Id to the session if it's not already set.
session.ContextId ??= contextId;
session.TaskId = taskId;
session.TaskState = taskState;
}
private static Message CreateA2AMessage(A2AAgentSession typedSession, IReadOnlyCollection<ChatMessage> messages)
{
var a2aMessage = messages.ToA2AMessage();
// Linking the message to the existing conversation, if any.
// See: https://github.com/a2aproject/A2A/blob/main/docs/topics/life-of-a-task.md#group-related-interactions
a2aMessage.ContextId = typedSession.ContextId;
if (typedSession.TaskState == TaskState.InputRequired)
{
// If the session indicates the task is waiting for user input,
// link the response to the existing task so it is treated as input
// for that task.
a2aMessage.TaskId = typedSession.TaskId;
}
else
{
// Link the message as a follow-up to an existing task, if any.
// See: https://github.com/a2aproject/A2A/blob/main/docs/topics/life-of-a-task.md#task-refinements
a2aMessage.ReferenceTaskIds = typedSession.TaskId is not null ? [typedSession.TaskId] : null;
}
return a2aMessage;
}
private static A2AContinuationToken? GetContinuationToken(IEnumerable<ChatMessage> messages, AgentRunOptions? options = null)
{
if (options?.ContinuationToken is ResponseContinuationToken token)
{
if (messages.Any())
{
throw new InvalidOperationException("Messages are not allowed when continuing a background response using a continuation token.");
}
return A2AContinuationToken.FromToken(token);
}
return null;
}
private static A2AContinuationToken? CreateContinuationToken(string taskId, TaskState state)
{
if (state is TaskState.Submitted or TaskState.Working)
{
return new A2AContinuationToken(taskId);
}
return null;
}
private AgentResponse ConvertToAgentResponse(Message message)
{
return new AgentResponse
{
AgentId = this.Id,
ResponseId = message.MessageId,
FinishReason = ChatFinishReason.Stop,
RawRepresentation = message,
Messages = [message.ToChatMessage()],
AdditionalProperties = message.Metadata?.ToAdditionalProperties(),
};
}
private AgentResponse ConvertToAgentResponse(AgentTask task)
{
return new AgentResponse
{
AgentId = this.Id,
ResponseId = task.Id,
FinishReason = MapTaskStateToFinishReason(task.Status.State),
RawRepresentation = task,
Messages = task.ToChatMessages() ?? [],
ContinuationToken = CreateContinuationToken(task.Id, task.Status.State),
AdditionalProperties = task.Metadata?.ToAdditionalProperties(),
};
}
private AgentResponseUpdate ConvertToAgentResponseUpdate(Message message)
{
return new AgentResponseUpdate
{
AgentId = this.Id,
ResponseId = message.MessageId,
FinishReason = ChatFinishReason.Stop,
RawRepresentation = message,
Role = ChatRole.Assistant,
MessageId = message.MessageId,
Contents = message.Parts.ConvertAll(part => part.ToAIContent()),
AdditionalProperties = message.Metadata?.ToAdditionalProperties(),
};
}
private AgentResponseUpdate ConvertToAgentResponseUpdate(AgentTask task)
{
return new AgentResponseUpdate
{
AgentId = this.Id,
ResponseId = task.Id,
FinishReason = MapTaskStateToFinishReason(task.Status.State),
RawRepresentation = task,
Role = ChatRole.Assistant,
Contents = task.ToAIContents(),
ContinuationToken = CreateContinuationToken(task.Id, task.Status.State),
AdditionalProperties = task.Metadata?.ToAdditionalProperties(),
};
}
private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskStatusUpdateEvent statusUpdateEvent)
{
return new AgentResponseUpdate
{
AgentId = this.Id,
ResponseId = statusUpdateEvent.TaskId,
RawRepresentation = statusUpdateEvent,
Role = ChatRole.Assistant,
MessageId = statusUpdateEvent.Status.Message?.MessageId,
FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State),
AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
Contents = statusUpdateEvent.Status.GetUserInputRequests(),
};
}
private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskArtifactUpdateEvent artifactUpdateEvent)
{
return new AgentResponseUpdate
{
AgentId = this.Id,
ResponseId = artifactUpdateEvent.TaskId,
RawRepresentation = artifactUpdateEvent,
Role = ChatRole.Assistant,
Contents = artifactUpdateEvent.Artifact.ToAIContents(),
AdditionalProperties = artifactUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
};
}
private static ChatFinishReason? MapTaskStateToFinishReason(TaskState state)
{
return state == TaskState.Completed ? ChatFinishReason.Stop : null;
}
}
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.A2A;
/// <summary>
/// Extensions for logging <see cref="A2AAgent"/> invocations.
/// </summary>
[ExcludeFromCodeCoverage]
internal static partial class A2AAgentLogMessages
{
/// <summary>
/// Logs <see cref="A2AAgent"/> invoking agent (started).
/// </summary>
[LoggerMessage(
Level = LogLevel.Debug,
Message = "[{MethodName}] A2AAgent {AgentId}/{AgentName} invoking underlying A2A agent.")]
public static partial void LogA2AAgentInvokingAgent(
this ILogger logger,
string methodName,
string agentId,
string? agentName);
/// <summary>
/// Logs <see cref="A2AAgent"/> invoked agent (complete).
/// </summary>
[LoggerMessage(
Level = LogLevel.Information,
Message = "[{MethodName}] A2AAgent {AgentId}/{AgentName} invoked underlying A2A agent.")]
public static partial void LogAgentChatClientInvokedAgent(
this ILogger logger,
string methodName,
string agentId,
string? agentName);
/// <summary>
/// Logs <see cref="A2AAgent"/> falling back to GetTaskAsync after SubscribeToTaskAsync failed with UnsupportedOperation.
/// </summary>
[LoggerMessage(
Level = LogLevel.Warning,
Message = "A2AAgent {AgentId}/{AgentName} SubscribeToTask for task '{TaskId}' failed with UnsupportedOperation: {ErrorMessage}. Falling back to GetTaskAsync.")]
public static partial void LogA2ASubscribeToTaskFallback(
this ILogger logger,
string agentId,
string? agentName,
string taskId,
string errorMessage);
}
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.A2A;
/// <summary>
/// Represents configuration options for an <see cref="A2AAgent"/>, including its identifier, name, and description.
/// </summary>
/// <remarks>
/// This class is used to encapsulate information about an A2A agent, such as its unique
/// identifier, display name, and a descriptive summary. It provides an alternative to passing
/// these values as individual constructor parameters.
/// </remarks>
public sealed class A2AAgentOptions
{
/// <summary>
/// Gets or sets the agent id.
/// </summary>
public string? Id { get; set; }
/// <summary>
/// Gets or sets the agent name.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the agent description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Creates a new instance of <see cref="A2AAgentOptions"/> with the same values as this instance.
/// </summary>
public A2AAgentOptions Clone()
=> new()
{
Id = this.Id,
Name = this.Name,
Description = this.Description
};
}
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using TaskState = A2A.TaskState;
namespace Microsoft.Agents.AI.A2A;
/// <summary>
/// Session for A2A based agents.
/// </summary>
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class A2AAgentSession : AgentSession
{
internal A2AAgentSession()
{
}
[JsonConstructor]
internal A2AAgentSession(string? contextId, string? taskId, TaskState? taskState, AgentSessionStateBag? stateBag) : base(stateBag ?? new())
{
this.ContextId = contextId;
this.TaskId = taskId;
this.TaskState = taskState;
}
/// <summary>
/// Gets the ID for the current conversation with the A2A agent.
/// </summary>
[JsonPropertyName("contextId")]
public string? ContextId { get; internal set; }
/// <summary>
/// Gets the ID for the task the agent is currently working on.
/// </summary>
[JsonPropertyName("taskId")]
public string? TaskId { get; internal set; }
/// <summary>
/// Gets the state of the task the agent is currently working on.
/// </summary>
[JsonPropertyName("taskState")]
public TaskState? TaskState { get; internal set; }
/// <inheritdoc/>
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
var jso = jsonSerializerOptions ?? A2AJsonUtilities.DefaultOptions;
return JsonSerializer.SerializeToElement(this, jso.GetTypeInfo(typeof(A2AAgentSession)));
}
internal static A2AAgentSession Deserialize(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null)
{
if (serializedState.ValueKind != JsonValueKind.Object)
{
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedState));
}
var jso = jsonSerializerOptions ?? A2AJsonUtilities.DefaultOptions;
return serializedState.Deserialize(jso.GetTypeInfo(typeof(A2AAgentSession))) as A2AAgentSession
?? new A2AAgentSession();
}
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay =>
$"ContextId = {this.ContextId}, TaskId = {this.TaskId}, TaskState = {this.TaskState}, StateBag Count = {this.StateBag.Count}";
}
@@ -0,0 +1,81 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.Text.Json;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.A2A;
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
internal class A2AContinuationToken : ResponseContinuationToken
{
internal A2AContinuationToken(string taskId)
{
_ = Throw.IfNullOrEmpty(taskId);
this.TaskId = taskId;
}
internal string TaskId { get; }
internal static A2AContinuationToken FromToken(ResponseContinuationToken token)
{
if (token is A2AContinuationToken longRunContinuationToken)
{
return longRunContinuationToken;
}
ReadOnlyMemory<byte> data = token.ToBytes();
if (data.Length == 0)
{
Throw.ArgumentException(nameof(token), "Failed to create A2AContinuationToken from provided token because it does not contain any data.");
}
Utf8JsonReader reader = new(data.Span);
string taskId = null!;
reader.Read();
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
{
break;
}
string propertyName = reader.GetString() ?? throw new JsonException("Failed to read property name from continuation token.");
switch (propertyName)
{
case "taskId":
reader.Read();
taskId = reader.GetString() ?? throw new JsonException("The 'taskId' property must contain a non-null string value.");
break;
default:
throw new JsonException($"Unrecognized property '{propertyName}'.");
}
}
return new(taskId);
}
public override ReadOnlyMemory<byte> ToBytes()
{
using MemoryStream stream = new();
using Utf8JsonWriter writer = new(stream);
writer.WriteStartObject();
writer.WriteString("taskId", this.TaskId);
writer.WriteEndObject();
writer.Flush();
stream.Position = 0;
return stream.ToArray();
}
}
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.A2A;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides utility methods and configurations for JSON serialization operations for A2A agent types.
/// </summary>
public static partial class A2AJsonUtilities
{
/// <summary>
/// Gets the default <see cref="JsonSerializerOptions"/> instance used for JSON serialization operations of A2A agent types.
/// </summary>
/// <remarks>
/// <para>
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
/// includes source generated contracts for A2A agent types.
/// </para>
/// <para>
/// It additionally turns on the following settings:
/// <list type="number">
/// <item><description>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</description></item>
/// <item><description>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</description></item>
/// <item><description>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</description></item>
/// <item><description>
/// Enables <see cref="JavaScriptEncoder.UnsafeRelaxedJsonEscaping"/> when escaping JSON strings.
/// Consuming applications must ensure that JSON outputs are adequately escaped before embedding in other document formats, such as HTML and XML.
/// </description></item>
/// </list>
/// </para>
/// </remarks>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
/// <summary>
/// Creates and configures the default JSON serialization options for agent abstraction types.
/// </summary>
/// <returns>The configured options.</returns>
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
private static JsonSerializerOptions CreateDefaultOptions()
{
// Copy the configuration from the source generated context.
JsonSerializerOptions options = new(JsonContext.Default.Options)
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // same as AIJsonUtilities
};
// Chain in the resolvers from both AIJsonUtilities and our source generated context.
// We want AIJsonUtilities first to ensure any M.E.AI types are handled via its resolver.
options.TypeInfoResolverChain.Clear();
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
// If reflection-based serialization is enabled by default, this includes
// the default type info resolver that utilizes reflection, but we need to manually
// apply the same converter AIJsonUtilities adds for string-based enum serialization,
// as that's not propagated as part of the resolver.
if (JsonSerializer.IsReflectionEnabledByDefault)
{
options.Converters.Add(new JsonStringEnumConverter());
}
options.MakeReadOnly();
return options;
}
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
// A2A agent types
[JsonSerializable(typeof(A2AAgentSession))]
[ExcludeFromCodeCoverage]
private sealed partial class JsonContext : JsonSerializerContext;
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using A2A;
namespace Microsoft.Extensions.AI;
/// <summary>
/// Extension methods for the <see cref="AIContent"/> class.
/// </summary>
internal static class A2AAIContentExtensions
{
/// <summary>
/// Converts a collection of <see cref="AIContent"/> to a list of <see cref="Part"/> objects.
/// </summary>
/// <param name="contents">The collection of AI contents to convert.</param>
/// <returns>The list of A2A <see cref="Part"/> objects.</returns>
internal static List<Part>? ToParts(this IEnumerable<AIContent> contents)
{
List<Part>? parts = null;
foreach (var content in contents)
{
if (content.ToPart() is { } part)
{
(parts ??= []).Add(part);
}
}
return parts;
}
}
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net.Http;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.A2A;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace A2A;
/// <summary>
/// Provides extension methods for <see cref="AgentCard"/> to simplify the creation of A2A agents.
/// </summary>
/// <remarks>
/// These extensions bridge the gap between A2A SDK client <see cref="AgentCard"/> and <see cref="AIAgent"/>.
/// </remarks>
public static class A2AAgentCardExtensions
{
/// <summary>
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
/// </summary>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
/// <param name="card">The <see cref="AgentCard" /> to use for the agent creation.</param>
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
/// <param name="options">
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
/// </param>
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, A2AClientOptions? options = null, ILoggerFactory? loggerFactory = null)
{
var a2aClient = A2AClientFactory.Create(card, httpClient, options);
return a2aClient.AsAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory);
}
/// <summary>
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
/// </summary>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism. When <paramref name="agentOptions"/> is provided, any non-null values override
/// the corresponding values from the <see cref="AgentCard"/>.
/// </remarks>
/// <param name="card">The <see cref="AgentCard" /> to use for the agent creation.</param>
/// <param name="agentOptions">
/// Configuration options that control the agent's identity. When provided, non-null values override the
/// corresponding values from the agent card.
/// </param>
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
/// <param name="clientOptions">
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
/// </param>
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static AIAgent AsAIAgent(this AgentCard card, A2AAgentOptions agentOptions, HttpClient? httpClient = null, A2AClientOptions? clientOptions = null, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(card);
_ = Throw.IfNull(agentOptions);
var a2aClient = A2AClientFactory.Create(card, httpClient, clientOptions);
var mergedOptions = agentOptions.Clone();
mergedOptions.Name ??= card.Name;
mergedOptions.Description ??= card.Description;
return a2aClient.AsAIAgent(mergedOptions, loggerFactory);
}
}
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace A2A;
/// <summary>
/// Extension methods for the <see cref="AgentTask"/> class.
/// </summary>
internal static class A2AAgentTaskExtensions
{
internal static IList<ChatMessage>? ToChatMessages(this AgentTask agentTask)
{
_ = Throw.IfNull(agentTask);
List<ChatMessage>? messages = null;
if (agentTask.Artifacts is { Count: > 0 })
{
foreach (var artifact in agentTask.Artifacts)
{
(messages ??= []).Add(artifact.ToChatMessage());
}
}
if (agentTask.Status?.GetUserInputRequests() is { } userInputRequests)
{
(messages ??= []).Add(new(ChatRole.Assistant, userInputRequests)
{
RawRepresentation = agentTask.Status,
});
}
return messages;
}
internal static IList<AIContent>? ToAIContents(this AgentTask agentTask)
{
_ = Throw.IfNull(agentTask);
List<AIContent>? aiContents = null;
if (agentTask.Artifacts is not null)
{
foreach (var artifact in agentTask.Artifacts)
{
(aiContents ??= []).AddRange(artifact.ToAIContents());
}
}
if (agentTask.Status?.GetUserInputRequests() is { } userInputRequests)
{
(aiContents ??= []).AddRange(userInputRequests);
}
return aiContents;
}
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace A2A;
/// <summary>
/// Extension methods for the <see cref="Artifact"/> class.
/// </summary>
internal static class A2AArtifactExtensions
{
internal static ChatMessage ToChatMessage(this Artifact artifact)
{
return new ChatMessage(ChatRole.Assistant, artifact.ToAIContents())
{
AdditionalProperties = artifact.Metadata.ToAdditionalProperties(),
RawRepresentation = artifact,
};
}
internal static List<AIContent> ToAIContents(this Artifact artifact)
{
return artifact.Parts.ConvertAll(part => part.ToAIContent());
}
}
@@ -0,0 +1,87 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.A2A;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace A2A;
/// <summary>
/// Provides extension methods for <see cref="A2ACardResolver"/>
/// to simplify the creation of A2A agents.
/// </summary>
/// <remarks>
/// These extensions bridge the gap between A2A SDK client objects
/// and the Microsoft Agent Framework.
/// <para>
/// They allow developers to easily create AI agents that can interact
/// with A2A agents by handling the conversion from A2A clients to
/// <see cref="A2AAgent"/> instances that implement the <see cref="AIAgent"/> interface.
/// </para>
/// </remarks>
public static class A2ACardResolverExtensions
{
/// <summary>
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
/// </summary>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#1-well-known-uri">Well-Known URI</see>
/// discovery mechanism.
/// </remarks>
/// <param name="resolver">The <see cref="A2ACardResolver" /> to use for the agent creation.</param>
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
/// <param name="options">
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
/// </param>
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static async Task<AIAgent> GetAIAgentAsync(this A2ACardResolver resolver, HttpClient? httpClient = null, A2AClientOptions? options = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
{
// Obtain the agent card from the resolver.
var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false);
return agentCard.AsAIAgent(httpClient, options, loggerFactory);
}
/// <summary>
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
/// </summary>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#1-well-known-uri">Well-Known URI</see>
/// discovery mechanism. When <paramref name="agentOptions"/> is provided, any non-null values override
/// the corresponding values from the resolved <see cref="AgentCard"/>.
/// </remarks>
/// <param name="resolver">The <see cref="A2ACardResolver" /> to use for the agent creation.</param>
/// <param name="agentOptions">
/// Configuration options that control the agent's identity. When provided, non-null values override the
/// corresponding values from the resolved agent card.
/// </param>
/// <param name="httpClient">
/// The <see cref="HttpClient"/> to use for HTTP requests made by the created A2A client.
/// This is not used for fetching the agent card; the resolver uses its own configured client for that.
/// </param>
/// <param name="clientOptions">
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
/// </param>
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static async Task<AIAgent> GetAIAgentAsync(this A2ACardResolver resolver, A2AAgentOptions agentOptions, HttpClient? httpClient = null, A2AClientOptions? clientOptions = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(agentOptions);
// Obtain the agent card from the resolver.
var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false);
return agentCard.AsAIAgent(agentOptions, httpClient, clientOptions, loggerFactory);
}
}
@@ -0,0 +1,63 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.A2A;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace A2A;
/// <summary>
/// Provides extension methods for <see cref="IA2AClient"/>
/// to simplify the creation of A2A agents.
/// </summary>
/// <remarks>
/// These extensions bridge the gap between A2A SDK client objects
/// and the Microsoft Agent Framework.
/// <para>
/// They allow developers to easily create AI agents that can interact
/// with A2A agents by handling the conversion from A2A clients to
/// <see cref="A2AAgent"/> instances that implement the <see cref="AIAgent"/> interface.
/// </para>
/// </remarks>
public static class A2AClientExtensions
{
/// <summary>
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
/// </summary>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#3-direct-configuration--private-discovery">Direct Configuration / Private Discovery</see>
/// discovery mechanism.
/// </remarks>
/// <param name="client">The <see cref="IA2AClient" /> to use for the agent.</param>
/// <param name="id">The unique identifier for the agent.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="description">The description of the agent.</param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static AIAgent AsAIAgent(this IA2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) =>
new A2AAgent(client, id, name, description, loggerFactory);
/// <summary>
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
/// </summary>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#3-direct-configuration--private-discovery">Direct Configuration / Private Discovery</see>
/// discovery mechanism.
/// </remarks>
/// <param name="client">The <see cref="IA2AClient" /> to use for the agent.</param>
/// <param name="options">
/// Configuration options that control the agent's identity, including its identifier, name, and description.
/// </param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static AIAgent AsAIAgent(this IA2AClient client, A2AAgentOptions options, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(client);
_ = Throw.IfNull(options);
return new A2AAgent(client, options, loggerFactory);
}
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace A2A;
/// <summary>
/// Extension methods for the <see cref="TaskStatus"/> class.
/// </summary>
internal static class AgentTaskStatusExtensions
{
internal static IList<AIContent>? GetUserInputRequests(this TaskStatus status)
{
_ = Throw.IfNull(status);
List<AIContent>? contents = null;
if (status.Message is null || status.State is not TaskState.InputRequired)
{
return contents;
}
foreach (var part in status.Message.Parts)
{
var aiContent = part.ToAIContent();
aiContent.RawRepresentation = part;
aiContent.AdditionalProperties = part.Metadata.ToAdditionalProperties();
(contents ??= []).Add(aiContent);
}
return contents;
}
}
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using A2A;
namespace Microsoft.Extensions.AI;
/// <summary>
/// Extension methods for the <see cref="ChatMessage"/> class.
/// </summary>
internal static class ChatMessageExtensions
{
internal static Message ToA2AMessage(this IEnumerable<ChatMessage> messages)
{
List<Part> allParts = [];
foreach (var message in messages)
{
if (message.Contents.ToParts() is { Count: > 0 } ps)
{
allParts.AddRange(ps);
}
}
return new Message
{
MessageId = Guid.NewGuid().ToString("N"),
Role = Role.User,
Parts = allParts,
};
}
}
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<VersionSuffix>preview</VersionSuffix>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="A2A" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework A2A</Title>
<Description>Provides Microsoft Agent Framework support for Agent2Agent (A2A) protocol.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.A2A.UnitTests" />
</ItemGroup>
</Project>
@@ -0,0 +1,104 @@
# Microsoft.Agents.AI.AGUI has moved
> [!IMPORTANT]
> The in-tree `Microsoft.Agents.AI.AGUI` package has been **removed**. Its AG-UI protocol
> abstractions now live in the official **AG-UI C# SDK** (`AGUI.*` packages) published on NuGet.org
> by the AG-UI team. This folder is intentionally kept only to host this migration note.
## Why it moved
Microsoft Agent Framework used to carry its own copy of the AG-UI protocol (events, messages, tools,
interrupts, state, multimodal, SSE and protobuf transports). That protocol is now maintained once, in
the AG-UI C# SDK, and stays wire compatible with the TypeScript and Python SDKs. MAF no longer tracks
protocol changes in a second implementation and keeps only the ASP.NET hosting glue that is genuinely
framework specific.
The programming model is unchanged: the SDK is built on `Microsoft.Extensions.AI`, so `IChatClient`
remains the single integration point on both the client and the server.
## Where things went
| You used (old) | Use this now (NuGet) |
| --- | --- |
| `Microsoft.Agents.AI.AGUI` (client) | [`AGUI.Client`](https://www.nuget.org/packages/AGUI.Client) |
| `Microsoft.Agents.AI.AGUI` (server adapters) | [`AGUI.Server`](https://www.nuget.org/packages/AGUI.Server) |
| `Microsoft.Agents.AI.AGUI` (events / messages / tools) | [`AGUI.Abstractions`](https://www.nuget.org/packages/AGUI.Abstractions) |
| protobuf transport (opt in) | [`AGUI.Protobuf`](https://www.nuget.org/packages/AGUI.Protobuf) |
| wire formatting helpers | [`AGUI.Formatting`](https://www.nuget.org/packages/AGUI.Formatting) |
The ASP.NET hosting integration stays in this repository as
[`Microsoft.Agents.AI.Hosting.AGUI.AspNetCore`](../Microsoft.Agents.AI.Hosting.AGUI.AspNetCore),
now layered over the `AGUI.Server` primitives.
Source of the SDK packages: [ag-ui-protocol/ag-ui](https://github.com/ag-ui-protocol/ag-ui)
(see [PR #1963](https://github.com/ag-ui-protocol/ag-ui/pull/1963)).
## Migration guide
### 1. Package references
Drop the reference to `Microsoft.Agents.AI.AGUI` and add the `AGUI.*` packages you actually use
(`AGUI.Client` for clients, `AGUI.Server` plus `AGUI.Abstractions` for server and hosting, plus
`AGUI.Protobuf` if you opt into the protobuf transport).
### 2. Hosting entry points renamed
The two hosting methods were renamed to match the sibling `AddA2AServer` convention. The old names
are gone.
```csharp
// Before
builder.Services.AddAGUI();
app.MapAGUI("/", agent);
// After
builder.Services.AddAGUIServer();
app.MapAGUIServer("/", agent);
```
### 3. Namespaces
The single `Microsoft.Agents.AI.AGUI` namespace splits along the SDK package boundaries:
`AGUI.Client`, `AGUI.Server`, and `AGUI.Abstractions`.
### 4. `AGUIChatClient` construction
The positional constructor becomes options based.
```csharp
// Before
using Microsoft.Agents.AI.AGUI;
var chatClient = new AGUIChatClient(
httpClient,
serverUrl,
jsonSerializerOptions: AGUIClientSerializerContext.Default.Options);
// After
using AGUI.Client;
var chatClient = new AGUIChatClient(new(httpClient, serverUrl)
{
JsonSerializerOptions = AGUIClientSerializerContext.Default.Options,
});
```
### 5. Recovering the originating AG-UI input on the server
Read it back from `ChatOptions` via the SDK extension.
```csharp
using AGUI.Abstractions;
using AGUI.Server;
if (!chatOptions.TryGetRunAgentInput(out RunAgentInput? agentInput))
{
// not an AG-UI-originated request
}
```
## Samples
Working end to end samples that use the external packages live under:
- [`samples/02-agents/AGUI`](../../samples/02-agents/AGUI)
- [`samples/05-end-to-end/AGUIClientServer`](../../samples/05-end-to-end/AGUIClientServer)
- [`samples/05-end-to-end/AGUIWebChat`](../../samples/05-end-to-end/AGUIWebChat)
@@ -0,0 +1,507 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides the base abstraction for all AI agents, defining the core interface for agent interactions and conversation management.
/// </summary>
/// <remarks>
/// <see cref="AIAgent"/> serves as the foundational class for implementing AI agents that can participate in conversations
/// and process user requests. An agent instance may participate in multiple concurrent conversations, and each conversation
/// may involve multiple agents working together.
/// <para>
/// <strong>Security considerations:</strong> An <see cref="AIAgent"/> orchestrates data flow across trust boundaries —
/// messages are sent to external AI services, context providers, chat history stores, and function tools. Agent Framework
/// passes messages through as-is without validation or sanitization. Developers must be aware that:
/// <list type="bullet">
/// <item><description>User-supplied messages may contain prompt injection attempts designed to manipulate LLM behavior.</description></item>
/// <item><description>LLM responses should be treated as untrusted output — they may contain hallucinations, malicious payloads (e.g., scripts, SQL),
/// or content influenced by indirect prompt injection. Always validate and sanitize LLM output before rendering in HTML, executing as code,
/// or using in database queries.</description></item>
/// <item><description>Messages with different roles carry different trust levels: <c>system</c> messages have the highest trust and must be developer-controlled;
/// <c>user</c>, <c>assistant</c>, and <c>tool</c> messages should be treated as untrusted.</description></item>
/// </list>
/// </para>
/// </remarks>
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public abstract partial class AIAgent
{
private static readonly AsyncLocal<AgentRunContext?> s_currentContext = new();
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay =>
this.Name is { } name ? $"Id = {this.Id}, Name = {name}" : $"Id = {this.Id}";
/// <summary>
/// Gets the unique identifier for this agent instance.
/// </summary>
/// <value>
/// A unique string identifier for the agent. For in-memory agents, this defaults to a randomly-generated ID,
/// while service-backed agents typically use the identifier assigned by the backing service.
/// </value>
/// <remarks>
/// Agent identifiers are used for tracking, telemetry, and distinguishing between different
/// agent instances in multi-agent scenarios. They should remain stable for the lifetime
/// of the agent instance.
/// </remarks>
public string Id { get => this.IdCore ?? field; } = Guid.NewGuid().ToString("N");
/// <summary>
/// Gets a custom identifier for the agent, which can be overridden by derived classes.
/// </summary>
/// <value>
/// A string representing the agent's identifier, or <see langword="null"/> if the default ID should be used.
/// </value>
/// <remarks>
/// Derived classes can override this property to provide a custom identifier.
/// When <see langword="null"/> is returned, the <see cref="Id"/> property will use the default randomly-generated identifier.
/// </remarks>
protected virtual string? IdCore => null;
/// <summary>
/// Gets the human-readable name of the agent.
/// </summary>
/// <value>
/// The agent's name, or <see langword="null"/> if no name has been assigned.
/// </value>
/// <remarks>
/// The agent name is typically used for display purposes and to help users identify
/// the agent's purpose or capabilities in user interfaces.
/// </remarks>
public virtual string? Name { get; }
/// <summary>
/// Gets a description of the agent's purpose, capabilities, or behavior.
/// </summary>
/// <value>
/// A descriptive text explaining what the agent does, or <see langword="null"/> if no description is available.
/// </value>
/// <remarks>
/// The description helps models and users understand the agent's intended purpose and capabilities,
/// which is particularly useful in multi-agent systems.
/// </remarks>
public virtual string? Description { get; }
/// <summary>
/// Gets or sets the <see cref="AgentRunContext"/> for the current agent run.
/// </summary>
/// <remarks>
/// This value flows across async calls.
/// </remarks>
public static AgentRunContext? CurrentRunContext
{
get => s_currentContext.Value;
protected set => s_currentContext.Value = value;
}
/// <summary>Asks the <see cref="AIAgent"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
/// <param name="serviceType">The type of object being requested.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="serviceType"/> is <see langword="null"/>.</exception>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the <see cref="AIAgent"/>,
/// including itself or any services it might be wrapping. For example, to access the <see cref="AIAgentMetadata"/> for the instance,
/// <see cref="GetService"/> may be used to request it.
/// </remarks>
public virtual object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);
return serviceKey is null && serviceType.IsInstanceOfType(this)
? this
: null;
}
/// <summary>Asks the <see cref="AIAgent"/> for an object of type <typeparamref name="TService"/>.</summary>
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the <see cref="AIAgent"/>,
/// including itself or any services it might be wrapping.
/// </remarks>
public TService? GetService<TService>(object? serviceKey = null)
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
/// <summary>
/// Creates a new conversation session that is compatible with this agent.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A value task that represents the asynchronous operation. The task result contains a new <see cref="AgentSession"/> instance ready for use with this agent.</returns>
/// <remarks>
/// <para>
/// This method creates a fresh conversation session that can be used to maintain state
/// and context for interactions with this agent. Each session represents an independent
/// conversation session.
/// </para>
/// <para>
/// If the agent supports multiple session types, this method returns the default or
/// configured session type. For service-backed agents, the actual session creation
/// may be deferred until first use to optimize performance.
/// </para>
/// </remarks>
public ValueTask<AgentSession> CreateSessionAsync(CancellationToken cancellationToken = default)
=> this.CreateSessionCoreAsync(cancellationToken);
/// <summary>
/// Core implementation of session creation logic.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A value task that represents the asynchronous operation. The task result contains a new <see cref="AgentSession"/> instance ready for use with this agent.</returns>
/// <remarks>
/// This is the primary session creation method that implementations must override.
/// </remarks>
protected abstract ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Serializes an agent session to its JSON representation.
/// </summary>
/// <param name="session">The <see cref="AgentSession"/> to serialize.</param>
/// <param name="jsonSerializerOptions">Optional settings to customize the serialization process.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A value task that represents the asynchronous operation. The task result contains a <see cref="JsonElement"/> with the serialized session state.</returns>
/// <exception cref="ArgumentNullException"><paramref name="session"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The type of <paramref name="session"/> is not supported by this agent.</exception>
/// <remarks>
/// This method enables saving conversation sessions to persistent storage,
/// allowing conversations to resume across application restarts or be migrated between
/// different agent instances. Use <see cref="DeserializeSessionAsync"/> to restore the session.
/// <para>
/// <strong>Security consideration:</strong> Serialized sessions may contain conversation content, session identifiers,
/// and other potentially sensitive data including PII. Ensure that serialized session data is stored securely with
/// appropriate access controls and encryption at rest.
/// </para>
/// </remarks>
public ValueTask<JsonElement> SerializeSessionAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> this.SerializeSessionCoreAsync(session, jsonSerializerOptions, cancellationToken);
/// <summary>
/// Core implementation of session serialization logic.
/// </summary>
/// <param name="session">The <see cref="AgentSession"/> to serialize.</param>
/// <param name="jsonSerializerOptions">Optional settings to customize the serialization process.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A value task that represents the asynchronous operation. The task result contains a <see cref="JsonElement"/> with the serialized session state.</returns>
/// <remarks>
/// This is the primary session serialization method that implementations must override.
/// </remarks>
protected abstract ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default);
/// <summary>
/// Deserializes an agent session from its JSON serialized representation.
/// </summary>
/// <param name="serializedState">A <see cref="JsonElement"/> containing the serialized session state.</param>
/// <param name="jsonSerializerOptions">Optional settings to customize the deserialization process.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A value task that represents the asynchronous operation. The task result contains a restored <see cref="AgentSession"/> instance with the state from <paramref name="serializedState"/>.</returns>
/// <exception cref="ArgumentException">The <paramref name="serializedState"/> is not in the expected format.</exception>
/// <exception cref="JsonException">The serialized data is invalid or cannot be deserialized.</exception>
/// <remarks>
/// This method enables restoration of conversation sessions from previously saved state,
/// allowing conversations to resume across application restarts or be migrated between
/// different agent instances.
/// <para>
/// <strong>Security consideration:</strong> Restoring a session from an untrusted source is equivalent to accepting untrusted input.
/// Serialized sessions may contain conversation content, session identifiers, and potentially sensitive data. A compromised
/// storage backend could alter message roles to escalate trust, or inject adversarial content that influences LLM behavior.
/// Treat serialized session data as sensitive and ensure it is stored and transmitted securely.
/// </para>
/// </remarks>
public ValueTask<AgentSession> DeserializeSessionAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> this.DeserializeSessionCoreAsync(serializedState, jsonSerializerOptions, cancellationToken);
/// <summary>
/// Core implementation of session deserialization logic.
/// </summary>
/// <param name="serializedState">A <see cref="JsonElement"/> containing the serialized session state.</param>
/// <param name="jsonSerializerOptions">Optional settings to customize the deserialization process.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A value task that represents the asynchronous operation. The task result contains a restored <see cref="AgentSession"/> instance with the state from <paramref name="serializedState"/>.</returns>
/// <remarks>
/// This is the primary session deserialization method that implementations must override.
/// </remarks>
protected abstract ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default);
/// <summary>
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the session.
/// </summary>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with any response messages generated during invocation.
/// </param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse"/> with the agent's output.</returns>
/// <remarks>
/// This overload is useful when the agent has sufficient context from previous messages in the session
/// or from its initial configuration to generate a meaningful response without additional input.
/// </remarks>
public Task<AgentResponse> RunAsync(
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) =>
this.RunAsync([], session, options, cancellationToken);
/// <summary>
/// Runs the agent with a text message from the user.
/// </summary>
/// <param name="message">The user message to send to the agent.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input message and any response messages generated during invocation.
/// </param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse"/> with the agent's output.</returns>
/// <exception cref="ArgumentException"><paramref name="message"/> is <see langword="null"/>, empty, or contains only whitespace.</exception>
/// <remarks>
/// The provided text will be wrapped in a <see cref="ChatMessage"/> with the <see cref="ChatRole.User"/> role
/// before being sent to the agent. This is a convenience method for simple text-based interactions.
/// </remarks>
public Task<AgentResponse> RunAsync(
string message,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNullOrWhitespace(message);
return this.RunAsync(new ChatMessage(ChatRole.User, message), session, options, cancellationToken);
}
/// <summary>
/// Runs the agent with a single chat message.
/// </summary>
/// <param name="message">The chat message to send to the agent.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input message and any response messages generated during invocation.
/// </param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse"/> with the agent's output.</returns>
/// <exception cref="ArgumentNullException"><paramref name="message"/> is <see langword="null"/>.</exception>
public Task<AgentResponse> RunAsync(
ChatMessage message,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(message);
return this.RunAsync([message], session, options, cancellationToken);
}
/// <summary>
/// Runs the agent with a collection of chat messages, providing the core invocation logic that all other overloads delegate to.
/// </summary>
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input messages and any response messages generated during invocation.
/// </param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse"/> with the agent's output.</returns>
/// <remarks>
/// <para>
/// This method delegates to <see cref="RunCoreAsync"/> to perform the actual agent invocation. It handles collections of messages,
/// allowing for complex conversational scenarios including multi-turn interactions, function calls, and
/// context-rich conversations.
/// </para>
/// <para>
/// The messages are processed in the order provided and become part of the conversation history.
/// The agent's response will also be added to <paramref name="session"/> if one is provided.
/// </para>
/// <para>
/// <strong>Security consideration:</strong> Agent Framework does not validate or sanitize message content — it is passed through
/// to the underlying AI service as-is. If input messages include untrusted user content, developers should be aware of prompt injection risks.
/// System-role messages must be developer-controlled and should never contain end-user input.
/// </para>
/// </remarks>
public Task<AgentResponse> RunAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
CurrentRunContext = new(this, session, messages as IReadOnlyCollection<ChatMessage> ?? messages.ToList(), options);
return this.RunCoreAsync(messages, session, options, cancellationToken);
}
/// <summary>
/// Core implementation of the agent invocation logic with a collection of chat messages.
/// </summary>
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input messages and any response messages generated during invocation.
/// </param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse"/> with the agent's output.</returns>
/// <remarks>
/// <para>
/// This is the primary invocation method that implementations must override. It handles collections of messages,
/// allowing for complex conversational scenarios including multi-turn interactions, function calls, and
/// context-rich conversations.
/// </para>
/// <para>
/// The messages are processed in the order provided and become part of the conversation history.
/// The agent's response will also be added to <paramref name="session"/> if one is provided.
/// </para>
/// </remarks>
protected abstract Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Runs the agent in streaming mode without providing new input messages, relying on existing context and instructions.
/// </summary>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with any response messages generated during invocation.
/// </param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An asynchronous enumerable of <see cref="AgentResponseUpdate"/> instances representing the streaming response.</returns>
public IAsyncEnumerable<AgentResponseUpdate> RunStreamingAsync(
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) =>
this.RunStreamingAsync([], session, options, cancellationToken);
/// <summary>
/// Runs the agent in streaming mode with a text message from the user.
/// </summary>
/// <param name="message">The user message to send to the agent.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input message and any response messages generated during invocation.
/// </param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An asynchronous enumerable of <see cref="AgentResponseUpdate"/> instances representing the streaming response.</returns>
/// <exception cref="ArgumentException"><paramref name="message"/> is <see langword="null"/>, empty, or contains only whitespace.</exception>
/// <remarks>
/// The provided text will be wrapped in a <see cref="ChatMessage"/> with the <see cref="ChatRole.User"/> role.
/// Streaming invocation provides real-time updates as the agent generates its response.
/// </remarks>
public IAsyncEnumerable<AgentResponseUpdate> RunStreamingAsync(
string message,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNullOrWhitespace(message);
return this.RunStreamingAsync(new ChatMessage(ChatRole.User, message), session, options, cancellationToken);
}
/// <summary>
/// Runs the agent in streaming mode with a single chat message.
/// </summary>
/// <param name="message">The chat message to send to the agent.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input message and any response messages generated during invocation.
/// </param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An asynchronous enumerable of <see cref="AgentResponseUpdate"/> instances representing the streaming response.</returns>
/// <exception cref="ArgumentNullException"><paramref name="message"/> is <see langword="null"/>.</exception>
public IAsyncEnumerable<AgentResponseUpdate> RunStreamingAsync(
ChatMessage message,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(message);
return this.RunStreamingAsync([message], session, options, cancellationToken);
}
/// <summary>
/// Runs the agent in streaming mode with a collection of chat messages, providing the core streaming invocation logic.
/// </summary>
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input messages and any response updates generated during invocation.
/// </param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An asynchronous enumerable of <see cref="AgentResponseUpdate"/> instances representing the streaming response.</returns>
/// <remarks>
/// <para>
/// This method delegates to <see cref="RunCoreStreamingAsync"/> to perform the actual streaming invocation. It provides real-time
/// updates as the agent processes the input and generates its response, enabling more responsive user experiences.
/// </para>
/// <para>
/// Each <see cref="AgentResponseUpdate"/> represents a portion of the complete response, allowing consumers
/// to display partial results, implement progressive loading, or provide immediate feedback to users.
/// </para>
/// <para>
/// <strong>Security consideration:</strong> Agent Framework does not validate or sanitize message content — it is passed through
/// to the underlying AI service as-is. If input messages include untrusted user content, developers should be aware of prompt injection risks.
/// System-role messages must be developer-controlled and should never contain end-user input.
/// </para>
/// </remarks>
public async IAsyncEnumerable<AgentResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
AgentRunContext context = new(this, session, messages as IReadOnlyCollection<ChatMessage> ?? messages.ToList(), options);
CurrentRunContext = context;
await foreach (var update in this.RunCoreStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
// Restore context again when resuming after the caller code executes.
CurrentRunContext = context;
}
}
/// <summary>
/// Core implementation of the agent streaming invocation logic with a collection of chat messages.
/// </summary>
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input messages and any response updates generated during invocation.
/// </param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An asynchronous enumerable of <see cref="AgentResponseUpdate"/> instances representing the streaming response.</returns>
/// <remarks>
/// <para>
/// This is the primary streaming invocation method that implementations must override. It provides real-time
/// updates as the agent processes the input and generates its response, enabling more responsive user experiences.
/// </para>
/// <para>
/// Each <see cref="AgentResponseUpdate"/> represents a portion of the complete response, allowing consumers
/// to display partial results, implement progressive loading, or provide immediate feedback to users.
/// </para>
/// </remarks>
protected abstract IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides metadata information about an <see cref="AIAgent"/> instance.
/// </summary>
/// <remarks>
/// This class contains descriptive information about an agent that can be used for identification,
/// telemetry, and logging purposes.
/// </remarks>
[DebuggerDisplay("ProviderName = {ProviderName}")]
public sealed class AIAgentMetadata
{
/// <summary>
/// Initializes a new instance of the <see cref="AIAgentMetadata"/> class.
/// </summary>
/// <param name="providerName">
/// The name of the agent provider, if applicable. Where possible, this should map to the
/// appropriate name defined in the OpenTelemetry Semantic Conventions for Generative AI systems.
/// </param>
public AIAgentMetadata(string? providerName = null)
{
this.ProviderName = providerName;
}
/// <summary>
/// Gets the name of the agent provider.
/// </summary>
/// <value>
/// The provider name that identifies the underlying service or implementation powering the agent.
/// </value>
/// <remarks>
/// Where possible, this maps to the appropriate name defined in the
/// OpenTelemetry Semantic Conventions for Generative AI systems.
/// </remarks>
public string? ProviderName { get; }
}
@@ -0,0 +1,140 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides structured output methods for <see cref="AIAgent"/> that enable requesting responses in a specific type format.
/// </summary>
public abstract partial class AIAgent
{
/// <summary>
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the session, and requesting a response of the specified type <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type of structured output to request.</typeparam>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with any response messages generated during invocation.
/// </param>
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
/// <remarks>
/// This overload is useful when the agent has sufficient context from previous messages in the session
/// or from its initial configuration to generate a meaningful response without additional input.
/// </remarks>
public Task<AgentResponse<T>> RunAsync<T>(
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) =>
this.RunAsync<T>([], session, serializerOptions, options, cancellationToken);
/// <summary>
/// Runs the agent with a text message from the user, requesting a response of the specified type <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type of structured output to request.</typeparam>
/// <param name="message">The user message to send to the agent.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input message and any response messages generated during invocation.
/// </param>
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
/// <exception cref="ArgumentException"><paramref name="message"/> is <see langword="null"/>, empty, or contains only whitespace.</exception>
/// <remarks>
/// The provided text will be wrapped in a <see cref="ChatMessage"/> with the <see cref="ChatRole.User"/> role
/// before being sent to the agent. This is a convenience method for simple text-based interactions.
/// </remarks>
public Task<AgentResponse<T>> RunAsync<T>(
string message,
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNullOrWhitespace(message);
return this.RunAsync<T>(new ChatMessage(ChatRole.User, message), session, serializerOptions, options, cancellationToken);
}
/// <summary>
/// Runs the agent with a single chat message, requesting a response of the specified type <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type of structured output to request.</typeparam>
/// <param name="message">The chat message to send to the agent.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input message and any response messages generated during invocation.
/// </param>
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
/// <exception cref="ArgumentNullException"><paramref name="message"/> is <see langword="null"/>.</exception>
public Task<AgentResponse<T>> RunAsync<T>(
ChatMessage message,
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(message);
return this.RunAsync<T>([message], session, serializerOptions, options, cancellationToken);
}
/// <summary>
/// Runs the agent with a collection of chat messages, requesting a response of the specified type <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type of structured output to request.</typeparam>
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
/// <param name="session">
/// The conversation session to use for this invocation. If <see langword="null"/>, a new session will be created.
/// The session will be updated with the input messages and any response messages generated during invocation.
/// </param>
/// <param name="serializerOptions">Optional JSON serializer options to use for deserializing the response.</param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentResponse{T}"/> with the agent's output.</returns>
/// <remarks>
/// <para>
/// This method handles collections of messages, allowing for complex conversational scenarios including
/// multi-turn interactions, function calls, and context-rich conversations.
/// </para>
/// <para>
/// The messages are processed in the order provided and become part of the conversation history.
/// The agent's response will also be added to <paramref name="session"/> if one is provided.
/// </para>
/// </remarks>
public async Task<AgentResponse<T>> RunAsync<T>(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
JsonSerializerOptions? serializerOptions = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions;
var responseFormat = ChatResponseFormat.ForJsonSchema<T>(serializerOptions);
(responseFormat, bool isWrappedInObject) = StructuredOutputSchemaUtilities.WrapNonObjectSchema(responseFormat);
options = options?.Clone() ?? new AgentRunOptions();
options.ResponseFormat = responseFormat;
AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false);
return new AgentResponse<T>(response, serializerOptions) { IsWrappedInObject = isWrappedInObject };
}
}
@@ -0,0 +1,117 @@
// Copyright (c) Microsoft. All rights reserved.
#if NET
using System;
#endif
using System.Collections.Generic;
using System.Linq;
#if NET
using System.Runtime.CompilerServices;
#else
using System.Text;
#endif
namespace Microsoft.Extensions.AI;
/// <summary>Internal extensions for working with <see cref="AIContent"/>.</summary>
internal static class AIContentExtensions
{
/// <summary>Concatenates the text of all <see cref="TextContent"/> instances in the list.</summary>
public static string ConcatText(this IEnumerable<AIContent> contents)
{
if (contents is IList<AIContent> list)
{
int count = list.Count;
switch (count)
{
case 0:
return string.Empty;
case 1:
return (list[0] as TextContent)?.Text ?? string.Empty;
default:
#if NET
DefaultInterpolatedStringHandler builder = new(count, 0, null, stackalloc char[512]);
for (int i = 0; i < count; i++)
{
if (list[i] is TextContent text)
{
builder.AppendLiteral(text.Text);
}
}
return builder.ToStringAndClear();
#else
StringBuilder builder = new();
for (int i = 0; i < count; i++)
{
if (list[i] is TextContent text)
{
builder.Append(text.Text);
}
}
return builder.ToString();
#endif
}
}
return string.Concat(contents.OfType<TextContent>());
}
/// <summary>Concatenates the <see cref="ChatMessage.Text"/> of all <see cref="ChatMessage"/> instances in the list.</summary>
/// <remarks>A newline separator is added between each non-empty piece of text.</remarks>
public static string ConcatText(this IList<ChatMessage> messages)
{
int count = messages.Count;
switch (count)
{
case 0:
return string.Empty;
case 1:
return messages[0].Text;
default:
#if NET
DefaultInterpolatedStringHandler builder = new(count, 0, null, stackalloc char[512]);
bool needsSeparator = false;
for (int i = 0; i < count; i++)
{
string text = messages[i].Text;
if (text.Length > 0)
{
if (needsSeparator)
{
builder.AppendLiteral(Environment.NewLine);
}
builder.AppendLiteral(text);
needsSeparator = true;
}
}
return builder.ToStringAndClear();
#else
StringBuilder builder = new();
for (int i = 0; i < count; i++)
{
string text = messages[i].Text;
if (text.Length > 0)
{
if (builder.Length > 0)
{
builder.AppendLine();
}
builder.Append(text);
}
}
return builder.ToString();
#endif
}
}
}
@@ -0,0 +1,109 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents additional context information that can be dynamically provided to AI models during agent invocations.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="AIContext"/> serves as a container for contextual information that <see cref="AIContextProvider"/> instances
/// can supply to enhance AI model interactions. This context is merged with
/// the agent's base configuration before being passed to the underlying AI model.
/// </para>
/// <para>
/// The context system enables dynamic, runtime-specific enhancements to agent capabilities including:
/// <list type="bullet">
/// <item><description>Adding relevant background information from knowledge bases</description></item>
/// <item><description>Injecting task-specific instructions or guidelines</description></item>
/// <item><description>Providing specialized tools or functions for the current interaction</description></item>
/// <item><description>Including contextual messages that inform the AI about the current situation</description></item>
/// </list>
/// </para>
/// <para>
/// Context information is transient by default and applies only to the current invocation, however messages
/// added through the <see cref="Messages"/> property will be permanently incorporated into the conversation history.
/// </para>
/// </remarks>
public sealed class AIContext
{
/// <summary>
/// Gets or sets additional instructions to provide to the AI model for the current invocation.
/// </summary>
/// <value>
/// Instructions text that will be combined with any existing agent instructions or system prompts,
/// or <see langword="null"/> if no additional instructions should be provided.
/// </value>
/// <remarks>
/// <para>
/// These instructions are transient and apply only to the current AI model invocation. They are combined
/// with any existing agent instructions, system prompts, and conversation history to provide comprehensive
/// context to the AI model.
/// </para>
/// <para>
/// Instructions can be used to:
/// <list type="bullet">
/// <item><description>Provide context-specific behavioral guidance</description></item>
/// <item><description>Add domain-specific knowledge or constraints</description></item>
/// <item><description>Modify the agent's persona or response style for the current interaction</description></item>
/// <item><description>Include situational awareness information</description></item>
/// </list>
/// </para>
/// </remarks>
public string? Instructions { get; set; }
/// <summary>
/// Gets or sets the sequence of messages to use for the current invocation.
/// </summary>
/// <value>
/// A sequence of <see cref="ChatMessage"/> instances to be used for the current invocation,
/// or <see langword="null"/> if no messages should be used.
/// </value>
/// <remarks>
/// <para>
/// Unlike <see cref="Instructions"/> and <see cref="Tools"/>, messages added through this property may become
/// permanent additions to the conversation history.
/// If chat history is managed by the underlying AI service, these messages will become part of chat history.
/// If chat history is managed using a <see cref="ChatHistoryProvider"/>, these messages will be passed to the
/// <see cref="ChatHistoryProvider.InvokedCoreAsync(ChatHistoryProvider.InvokedContext, System.Threading.CancellationToken)"/> method,
/// and the provider can choose which of these messages to permanently add to the conversation history.
/// </para>
/// <para>
/// This property is useful for:
/// <list type="bullet">
/// <item><description>Injecting relevant historical context e.g. memories</description></item>
/// <item><description>Injecting relevant background information e.g. via Retrieval Augmented Generation</description></item>
/// <item><description>Adding system messages that provide ongoing context</description></item>
/// </list>
/// </para>
/// </remarks>
public IEnumerable<ChatMessage>? Messages { get; set; }
/// <summary>
/// Gets or sets a sequence of tools or functions to make available to the AI model for the current invocation.
/// </summary>
/// <value>
/// A sequence of <see cref="AITool"/> instances that will be available to the AI model during the current invocation,
/// or <see langword="null"/> if no additional tools should be provided.
/// </value>
/// <remarks>
/// <para>
/// These tools are transient and apply only to the current AI model invocation. Any existing tools
/// are provided as input to the <see cref="AIContextProvider"/> instances, so context providers can choose to modify or replace the existing tools
/// as needed based on the current context. The resulting set of tools is then passed to the underlying AI model, which may choose to utilize them when generating responses.
/// </para>
/// <para>
/// Context-specific tools enable:
/// <list type="bullet">
/// <item><description>Providing specialized functions based on user intent or conversation context</description></item>
/// <item><description>Adding domain-specific capabilities for particular types of queries</description></item>
/// <item><description>Enabling access to external services or data sources relevant to the current task</description></item>
/// <item><description>Offering interactive capabilities tailored to the current conversation state</description></item>
/// </list>
/// </para>
/// </remarks>
public IEnumerable<AITool>? Tools { get; set; }
}
@@ -0,0 +1,511 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides an abstract base class for components that enhance AI context during agent invocations.
/// </summary>
/// <remarks>
/// <para>
/// An AI context provider is a component that participates in the agent invocation lifecycle by:
/// <list type="bullet">
/// <item><description>Listening to changes in conversations</description></item>
/// <item><description>Providing additional context to agents during invocation</description></item>
/// <item><description>Supplying additional function tools for enhanced capabilities</description></item>
/// <item><description>Processing invocation results for state management or learning</description></item>
/// </list>
/// </para>
/// <para>
/// Context providers operate through a two-phase lifecycle: they are called at the start of invocation via
/// <see cref="InvokingAsync"/> to provide context, and optionally called at the end of invocation via
/// <see cref="InvokedAsync"/> to process results.
/// </para>
/// <para>
/// <strong>Security considerations:</strong> Context providers may inject messages with any role, including <c>system</c>, which
/// has the highest trust level and directly shapes LLM behavior. Developers must ensure that all providers attached to an agent
/// are trusted. Agent Framework does not validate or filter the data returned by providers — it is accepted as-is and merged into
/// the request context. If a provider retrieves data from an external source (e.g., a vector database or memory service), be aware
/// that a compromised data source could introduce adversarial content designed to manipulate LLM behavior via indirect prompt injection.
/// Implementers should validate and sanitize data retrieved from external sources before returning it.
/// </para>
/// </remarks>
public abstract class AIContextProvider
{
private static IEnumerable<ChatMessage> DefaultExternalOnlyFilter(IEnumerable<ChatMessage> messages)
=> messages.Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External);
private static IEnumerable<ChatMessage> DefaultNoopFilter(IEnumerable<ChatMessage> messages)
=> messages;
private IReadOnlyList<string>? _stateKeys;
/// <summary>
/// Initializes a new instance of the <see cref="AIContextProvider"/> class.
/// </summary>
/// <param name="provideInputMessageFilter">An optional filter function to apply to input messages before providing context via <see cref="ProvideAIContextAsync"/>. If not set, defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages.</param>
/// <param name="storeInputRequestMessageFilter">An optional filter function to apply to request messages before storing context via <see cref="StoreAIContextAsync"/>. If not set, defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages.</param>
/// <param name="storeInputResponseMessageFilter">An optional filter function to apply to response messages before storing context via <see cref="StoreAIContextAsync"/>. If not set, defaults to a no-op filter that includes all response messages.</param>
protected AIContextProvider(
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideInputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputRequestMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputResponseMessageFilter = null)
{
this.ProvideInputMessageFilter = provideInputMessageFilter ?? DefaultExternalOnlyFilter;
this.StoreInputRequestMessageFilter = storeInputRequestMessageFilter ?? DefaultExternalOnlyFilter;
this.StoreInputResponseMessageFilter = storeInputResponseMessageFilter ?? DefaultNoopFilter;
}
/// <summary>
/// Gets the filter function to apply to input messages before providing context via <see cref="ProvideAIContextAsync"/>.
/// </summary>
protected Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> ProvideInputMessageFilter { get; }
/// <summary>
/// Gets the filter function to apply to request messages before storing context via <see cref="StoreAIContextAsync"/>.
/// </summary>
protected Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> StoreInputRequestMessageFilter { get; }
/// <summary>
/// Gets the filter function to apply to response messages before storing context via <see cref="StoreAIContextAsync"/>.
/// </summary>
protected Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> StoreInputResponseMessageFilter { get; }
/// <summary>
/// Gets the set of keys used to store the provider state in the <see cref="AgentSession.StateBag"/>.
/// </summary>
/// <remarks>
/// The default value is a single-element set containing the name of the concrete type (e.g. <c>"TextSearchProvider"</c>).
/// Implementations may override this to provide custom keys, for example when multiple
/// instances of the same provider type are used in the same session, or when a provider
/// stores state under more than one key.
/// </remarks>
public virtual IReadOnlyList<string> StateKeys => this._stateKeys ??= [this.GetType().Name];
/// <summary>
/// Called at the start of agent invocation to provide additional context.
/// </summary>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the <see cref="AIContext"/> with additional context to be used by the agent during this invocation.</returns>
/// <remarks>
/// <para>
/// Implementers can load any additional context required at this time, such as:
/// <list type="bullet">
/// <item><description>Retrieving relevant information from knowledge bases</description></item>
/// <item><description>Adding system instructions or prompts</description></item>
/// <item><description>Providing function tools for the current invocation</description></item>
/// <item><description>Injecting contextual messages from conversation history</description></item>
/// </list>
/// </para>
/// <para>
/// <strong>Security consideration:</strong> Data retrieved from external sources (e.g., vector databases, memory services, or
/// knowledge bases) may contain adversarial content designed to influence LLM behavior via indirect prompt injection.
/// Implementers should validate data integrity and consider the trustworthiness of the data source.
/// </para>
/// </remarks>
public ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
=> this.InvokingCoreAsync(Throw.IfNull(context), cancellationToken);
/// <summary>
/// Called at the start of agent invocation to provide additional context.
/// </summary>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the <see cref="AIContext"/> with additional context to be used by the agent during this invocation.</returns>
/// <remarks>
/// <para>
/// Implementers can load any additional context required at this time, such as:
/// <list type="bullet">
/// <item><description>Retrieving relevant information from knowledge bases</description></item>
/// <item><description>Adding system instructions or prompts</description></item>
/// <item><description>Providing function tools for the current invocation</description></item>
/// <item><description>Injecting contextual messages from conversation history</description></item>
/// </list>
/// </para>
/// <para>
/// The default implementation of this method filters the input messages using the configured provide-input message filter
/// (which defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages),
/// then calls <see cref="ProvideAIContextAsync"/> to get additional context,
/// stamps any messages from the returned context with <see cref="AgentRequestMessageSourceType.AIContextProvider"/> source attribution,
/// and merges the returned context with the original (unfiltered) input context (concatenating instructions, messages, and tools).
/// For most scenarios, overriding <see cref="ProvideAIContextAsync"/> is sufficient to provide additional context,
/// while still benefiting from the default filtering, merging and source stamping behavior.
/// However, for scenarios that require more control over context filtering, merging or source stamping, overriding this method
/// allows you to directly control the full <see cref="AIContext"/> returned for the invocation.
/// </para>
/// </remarks>
protected virtual async ValueTask<AIContext> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
var inputContext = context.AIContext;
// Create a filtered context for ProvideAIContextAsync, filtering input messages
// to exclude non-external messages (e.g. chat history, other AI context provider messages).
#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
var filteredContext = new InvokingContext(
context.Agent,
context.Session,
new AIContext
{
Instructions = inputContext.Instructions,
Messages = inputContext.Messages is not null ? this.ProvideInputMessageFilter(inputContext.Messages) : null,
Tools = inputContext.Tools
});
#pragma warning restore MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
var provided = await this.ProvideAIContextAsync(filteredContext, cancellationToken).ConfigureAwait(false);
var mergedInstructions = (inputContext.Instructions, provided.Instructions) switch
{
(null, null) => null,
(string a, null) => a,
(null, string b) => b,
(string a, string b) => a + "\n" + b
};
var providedMessages = provided.Messages is not null
? provided.Messages.Select(m => m.WithAgentRequestMessageSource(AgentRequestMessageSourceType.AIContextProvider, this.GetType().FullName!))
: null;
var mergedMessages = (inputContext.Messages, providedMessages) switch
{
(null, null) => null,
(var a, null) => a,
(null, var b) => b,
(var a, var b) => a.Concat(b)
};
var mergedTools = (inputContext.Tools, provided.Tools) switch
{
(null, null) => null,
(var a, null) => a,
(null, var b) => b,
(var a, var b) => a.Concat(b)
};
return new AIContext
{
Instructions = mergedInstructions,
Messages = mergedMessages,
Tools = mergedTools
};
}
/// <summary>
/// When overridden in a derived class, provides additional AI context to be merged with the input context for the current invocation.
/// </summary>
/// <remarks>
/// <para>
/// This method is called from <see cref="InvokingCoreAsync"/>.
/// Note that <see cref="InvokingCoreAsync"/> can be overridden to directly control context merging and source stamping, in which case
/// it is up to the implementer to call this method as needed to retrieve the additional context.
/// </para>
/// <para>
/// In contrast with <see cref="InvokingCoreAsync"/>, this method only returns additional context to be merged with the input,
/// while <see cref="InvokingCoreAsync"/> is responsible for returning the full merged <see cref="AIContext"/> for the invocation.
/// </para>
/// <para>
/// <strong>Security consideration:</strong> Any messages, tools, or instructions returned by this method will be merged into the
/// AI request context. If data is retrieved from external or untrusted sources, implementers should validate and sanitize it
/// to prevent indirect prompt injection attacks.
/// </para>
/// </remarks>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains an <see cref="AIContext"/>
/// with additional context to be merged with the input context.
/// </returns>
protected virtual ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
return new ValueTask<AIContext>(new AIContext());
}
/// <summary>
/// Called at the end of the agent invocation to process the invocation results.
/// </summary>
/// <param name="context">Contains the invocation context including request messages, response messages, and any exception that occurred.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <remarks>
/// <para>
/// Implementers can use the request and response messages in the provided <paramref name="context"/> to:
/// <list type="bullet">
/// <item><description>Update state based on conversation outcomes</description></item>
/// <item><description>Extract and store memories or preferences from user messages</description></item>
/// <item><description>Log or audit conversation details</description></item>
/// <item><description>Perform cleanup or finalization tasks</description></item>
/// </list>
/// </para>
/// <para>
/// The <see cref="AIContextProvider"/> is passed a reference to the <see cref="AgentSession"/> via <see cref="InvokingContext"/> and <see cref="InvokedContext"/>
/// allowing it to store state in the <see cref="AgentSession.StateBag"/>. Since an <see cref="AIContextProvider"/> is used with many different sessions, it should
/// not store any session-specific information within its own instance fields. Instead, any session-specific state should be stored in the associated <see cref="AgentSession.StateBag"/>.
/// </para>
/// <para>
/// This method is called regardless of whether the invocation succeeded or failed.
/// To check if the invocation was successful, inspect the <see cref="InvokedContext.InvokeException"/> property.
/// </para>
/// </remarks>
public ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
=> this.InvokedCoreAsync(Throw.IfNull(context), cancellationToken);
/// <summary>
/// Called at the end of the agent invocation to process the invocation results.
/// </summary>
/// <param name="context">Contains the invocation context including request messages, response messages, and any exception that occurred.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <remarks>
/// <para>
/// Implementers can use the request and response messages in the provided <paramref name="context"/> to:
/// <list type="bullet">
/// <item><description>Update internal state based on conversation outcomes</description></item>
/// <item><description>Extract and store memories or preferences from user messages</description></item>
/// <item><description>Log or audit conversation details</description></item>
/// <item><description>Perform cleanup or finalization tasks</description></item>
/// </list>
/// </para>
/// <para>
/// This method is called regardless of whether the invocation succeeded or failed.
/// To check if the invocation was successful, inspect the <see cref="InvokedContext.InvokeException"/> property.
/// </para>
/// <para>
/// The default implementation of this method skips execution for any invocation failures,
/// filters the request messages using the configured store-input request message filter
/// (which defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages),
/// filters the response messages using the configured store-input response message filter
/// (which defaults to a no-op, so all response messages are processed),
/// and calls <see cref="StoreAIContextAsync"/> to process the invocation results.
/// For most scenarios, overriding <see cref="StoreAIContextAsync"/> is sufficient to process invocation results,
/// while still benefiting from the default error handling and filtering behavior.
/// However, for scenarios that require more control over error handling or message filtering, overriding this method
/// allows you to directly control the processing of invocation results.
/// </para>
/// </remarks>
protected virtual ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
if (context.InvokeException is not null)
{
return default;
}
#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
var subContext = new InvokedContext(context.Agent, context.Session, this.StoreInputRequestMessageFilter(context.RequestMessages), this.StoreInputResponseMessageFilter(context.ResponseMessages!));
#pragma warning restore MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
return this.StoreAIContextAsync(subContext, cancellationToken);
}
/// <summary>
/// When overridden in a derived class, processes invocation results at the end of the agent invocation.
/// </summary>
/// <param name="context">Contains the invocation context including request messages, response messages, and any exception that occurred.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <remarks>
/// <para>
/// This method is called from <see cref="InvokedCoreAsync"/>.
/// Note that <see cref="InvokedCoreAsync"/> can be overridden to directly control error handling, in which case
/// it is up to the implementer to call this method as needed to process the invocation results.
/// </para>
/// <para>
/// In contrast with <see cref="InvokedCoreAsync"/>, this method only processes the invocation results,
/// while <see cref="InvokedCoreAsync"/> is also responsible for error handling.
/// </para>
/// <para>
/// The default implementation of <see cref="InvokedCoreAsync"/> only calls this method if the invocation succeeded.
/// </para>
/// <para>
/// <strong>Security consideration:</strong> Messages being processed/stored may contain PII and sensitive conversation content.
/// Implementers should ensure appropriate encryption at rest and access controls for the storage backend.
/// </para>
/// </remarks>
protected virtual ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default) =>
default;
/// <summary>Asks the <see cref="AIContextProvider"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
/// <param name="serviceType">The type of object being requested.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="serviceType"/> is <see langword="null"/>.</exception>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the <see cref="AIContextProvider"/>,
/// including itself or any services it might be wrapping. This enables advanced scenarios where consumers need access to
/// specific provider implementations or their internal services.
/// </remarks>
public virtual object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);
return serviceKey is null && serviceType.IsInstanceOfType(this)
? this
: null;
}
/// <summary>Asks the <see cref="AIContextProvider"/> for an object of type <typeparamref name="TService"/>.</summary>
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the <see cref="AIContextProvider"/>,
/// including itself or any services it might be wrapping. This is a convenience overload of <see cref="GetService(Type, object?)"/>.
/// </remarks>
public TService? GetService<TService>(object? serviceKey = null)
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
/// <summary>
/// Contains the context information provided to <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// This class provides context about the invocation before the underlying AI model is invoked, including the messages
/// that will be used. Context providers can use this information to determine what additional context
/// should be provided for the invocation.
/// </remarks>
public sealed class InvokingContext
{
/// <summary>
/// Initializes a new instance of the <see cref="InvokingContext"/> class.
/// </summary>
/// <param name="agent">The agent being invoked.</param>
/// <param name="session">The session associated with the agent invocation.</param>
/// <param name="aiContext">The AI context to be used by the agent for this invocation.</param>
/// <exception cref="ArgumentNullException"><paramref name="agent"/> or <paramref name="aiContext"/> is <see langword="null"/>.</exception>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public InvokingContext(
AIAgent agent,
AgentSession? session,
AIContext aiContext)
{
this.Agent = Throw.IfNull(agent);
this.Session = session;
this.AIContext = Throw.IfNull(aiContext);
}
/// <summary>
/// Gets the agent that is being invoked.
/// </summary>
public AIAgent Agent { get; }
/// <summary>
/// Gets the agent session associated with the agent invocation.
/// </summary>
public AgentSession? Session { get; }
/// <summary>
/// Gets the <see cref="AIContext"/> being built for the current invocation. Context providers can modify
/// and return or return a new <see cref="AIContext"/> instance to provide additional context for the invocation.
/// </summary>
/// <remarks>
/// <para>
/// If multiple <see cref="AIContextProvider"/> instances are used in the same invocation, each <see cref="AIContextProvider"/>
/// will receive the context returned by the previous <see cref="AIContextProvider"/> allowing them to build on top of each other's context.
/// </para>
/// <para>
/// The first <see cref="AIContextProvider"/> in the invocation pipeline will receive an <see cref="AIContext"/> instance
/// that already contains the caller provided messages that will be used by the agent for this invocation.
/// </para>
/// <para>
/// It may also contain messages from chat history, if a <see cref="ChatHistoryProvider"/> is being used.
/// </para>
/// </remarks>
public AIContext AIContext { get; }
}
/// <summary>
/// Contains the context information provided to <see cref="InvokedCoreAsync(InvokedContext, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// This class provides context about a completed agent invocation, including the accumulated
/// request messages (user input, chat history and any others provided by AI context providers) that were used
/// and the response messages that were generated. It also indicates whether the invocation succeeded or failed.
/// </remarks>
public sealed class InvokedContext
{
/// <summary>
/// Initializes a new instance of the <see cref="InvokedContext"/> class for a successful invocation.
/// </summary>
/// <param name="agent">The agent that was invoked.</param>
/// <param name="session">The session associated with the agent invocation.</param>
/// <param name="requestMessages">The accumulated request messages (user input, chat history and any others provided by AI context providers)
/// that were used by the agent for this invocation.</param>
/// <param name="responseMessages">The response messages generated during this invocation.</param>
/// <exception cref="ArgumentNullException"><paramref name="agent"/>, <paramref name="requestMessages"/>, or <paramref name="responseMessages"/> is <see langword="null"/>.</exception>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public InvokedContext(
AIAgent agent,
AgentSession? session,
IEnumerable<ChatMessage> requestMessages,
IEnumerable<ChatMessage> responseMessages)
{
this.Agent = Throw.IfNull(agent);
this.Session = session;
this.RequestMessages = Throw.IfNull(requestMessages);
this.ResponseMessages = Throw.IfNull(responseMessages);
}
/// <summary>
/// Initializes a new instance of the <see cref="InvokedContext"/> class for a failed invocation.
/// </summary>
/// <param name="agent">The agent that was invoked.</param>
/// <param name="session">The session associated with the agent invocation.</param>
/// <param name="requestMessages">The accumulated request messages (user input, chat history and any others provided by AI context providers)
/// that were used by the agent for this invocation.</param>
/// <param name="invokeException">The exception that caused the invocation to fail.</param>
/// <exception cref="ArgumentNullException"><paramref name="agent"/>, <paramref name="requestMessages"/>, or <paramref name="invokeException"/> is <see langword="null"/>.</exception>
public InvokedContext(
AIAgent agent,
AgentSession? session,
IEnumerable<ChatMessage> requestMessages,
Exception invokeException)
{
this.Agent = Throw.IfNull(agent);
this.Session = session;
this.RequestMessages = Throw.IfNull(requestMessages);
this.InvokeException = Throw.IfNull(invokeException);
}
/// <summary>
/// Gets the agent that is being invoked.
/// </summary>
public AIAgent Agent { get; }
/// <summary>
/// Gets the agent session associated with the agent invocation.
/// </summary>
public AgentSession? Session { get; }
/// <summary>
/// Gets the accumulated request messages (user input, chat history and any others provided by AI context providers)
/// that were used by the agent for this invocation.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing all messages that were used by the agent for this invocation.
/// </value>
public IEnumerable<ChatMessage> RequestMessages { get; }
/// <summary>
/// Gets the collection of response messages generated during this invocation if the invocation succeeded.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing the response,
/// or <see langword="null"/> if the invocation failed.
/// </value>
public IEnumerable<ChatMessage>? ResponseMessages { get; }
/// <summary>
/// Gets the <see cref="Exception"/> that was thrown during the invocation, if the invocation failed.
/// </summary>
/// <value>
/// The exception that caused the invocation to fail, or <see langword="null"/> if the invocation succeeded.
/// </value>
public Exception? InvokeException { get; }
}
}
@@ -0,0 +1,99 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Contains extension methods to allow storing and retrieving properties using the type name of the property as the key.
/// </summary>
public static class AdditionalPropertiesExtensions
{
/// <summary>
/// Adds an additional property using the type name of the property as the key.
/// </summary>
/// <typeparam name="T">The type of the property to add.</typeparam>
/// <param name="additionalProperties">The dictionary of additional properties.</param>
/// <param name="value">The value to add.</param>
public static void Add<T>(this AdditionalPropertiesDictionary additionalProperties, T value)
{
_ = Throw.IfNull(additionalProperties);
additionalProperties.Add(typeof(T).FullName!, value);
}
/// <summary>
/// Attempts to add a property using the type name of the property as the key.
/// </summary>
/// <remarks>
/// This method uses the full name of the type parameter as the key. If the key already exists,
/// the value is not updated and the method returns <see langword="false"/>.
/// </remarks>
/// <typeparam name="T">The type of the property to add.</typeparam>
/// <param name="additionalProperties">The dictionary of additional properties.</param>
/// <param name="value">The value to add.</param>
/// <returns>
/// <see langword="true"/> if the value was added successfully; <see langword="false"/> if the key already exists.
/// </returns>
public static bool TryAdd<T>(this AdditionalPropertiesDictionary additionalProperties, T value)
{
_ = Throw.IfNull(additionalProperties);
return additionalProperties.TryAdd(typeof(T).FullName!, value);
}
/// <summary>
/// Attempts to retrieve a value from the additional properties dictionary using the type name of the property as the key.
/// </summary>
/// <remarks>
/// This method uses the full name of the type parameter as the key when searching the dictionary.
/// </remarks>
/// <typeparam name="T">The type of the property to be retrieved.</typeparam>
/// <param name="additionalProperties">The dictionary containing additional properties.</param>
/// <param name="value">
/// When this method returns, contains the value retrieved from the dictionary, if found and successfully converted to the requested type;
/// otherwise, the default value of <typeparamref name="T"/>.
/// </param>
/// <returns>
/// <see langword="true"/> if a non-<see langword="null"/> value was found
/// in the dictionary and converted to the requested type; otherwise, <see langword="false"/>.
/// </returns>
public static bool TryGetValue<T>(this AdditionalPropertiesDictionary additionalProperties, [NotNullWhen(true)] out T? value)
{
_ = Throw.IfNull(additionalProperties);
return additionalProperties.TryGetValue(typeof(T).FullName!, out value);
}
/// <summary>
/// Determines whether the additional properties dictionary contains a property with the name of the provided type as the key.
/// </summary>
/// <typeparam name="T">The type of the property to check for.</typeparam>
/// <param name="additionalProperties">The dictionary of additional properties.</param>
/// <returns>
/// <see langword="true"/> if the dictionary contains a property with the name of the provided type as the key; otherwise, <see langword="false"/>.
/// </returns>
public static bool Contains<T>(this AdditionalPropertiesDictionary additionalProperties)
{
_ = Throw.IfNull(additionalProperties);
return additionalProperties.ContainsKey(typeof(T).FullName!);
}
/// <summary>
/// Removes a property from the additional properties dictionary using the name of the provided type as the key.
/// </summary>
/// <typeparam name="T">The type of the property to remove.</typeparam>
/// <param name="additionalProperties">The dictionary of additional properties.</param>
/// <returns>
/// <see langword="true"/> if the property was successfully removed; otherwise, <see langword="false"/>.
/// </returns>
public static bool Remove<T>(this AdditionalPropertiesDictionary additionalProperties)
{
_ = Throw.IfNull(additionalProperties);
return additionalProperties.Remove(typeof(T).FullName!);
}
}
@@ -0,0 +1,90 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides utility methods and configurations for JSON serialization operations within the Microsoft Agent Framework.
/// </summary>
public static partial class AgentAbstractionsJsonUtilities
{
/// <summary>
/// Gets the default <see cref="JsonSerializerOptions"/> instance used for JSON serialization operations of agent abstraction types.
/// </summary>
/// <remarks>
/// <para>
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
/// includes source generated contracts for all common exchange types contained in this library.
/// </para>
/// <para>
/// It additionally turns on the following settings:
/// <list type="number">
/// <item><description>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</description></item>
/// <item><description>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</description></item>
/// <item><description>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</description></item>
/// <item><description>
/// Enables <see cref="JavaScriptEncoder.UnsafeRelaxedJsonEscaping"/> when escaping JSON strings.
/// Consuming applications must ensure that JSON outputs are adequately escaped before embedding in other document formats, such as HTML and XML.
/// </description></item>
/// </list>
/// </para>
/// </remarks>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
/// <summary>
/// Creates and configures the default JSON serialization options for agent abstraction types.
/// </summary>
/// <returns>The configured options.</returns>
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
private static JsonSerializerOptions CreateDefaultOptions()
{
// Copy the configuration from the source generated context.
JsonSerializerOptions options = new(JsonContext.Default.Options)
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // same as AIJsonUtilities
};
// Chain in the resolvers from both AIJsonUtilities and our source generated context.
// We want AIJsonUtilities first to ensure any M.E.AI types are handled via its resolver.
options.TypeInfoResolverChain.Clear();
options.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.TypeInfoResolverChain.Add(JsonContext.Default.Options.TypeInfoResolver!);
// If reflection-based serialization is enabled by default, this includes
// the default type info resolver that utilizes reflection, but we need to manually
// apply the same converter AIJsonUtilities adds for string-based enum serialization,
// as that's not propagated as part of the resolver.
if (JsonSerializer.IsReflectionEnabledByDefault)
{
options.Converters.Add(new JsonStringEnumConverter());
}
options.MakeReadOnly();
return options;
}
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
// Agent abstraction types
[JsonSerializable(typeof(AgentRunOptions))]
[JsonSerializable(typeof(AgentResponse))]
[JsonSerializable(typeof(AgentResponse[]))]
[JsonSerializable(typeof(AgentResponseUpdate))]
[JsonSerializable(typeof(AgentResponseUpdate[]))]
[JsonSerializable(typeof(InMemoryChatHistoryProvider.State))]
[JsonSerializable(typeof(AgentSessionStateBag))]
[JsonSerializable(typeof(ConcurrentDictionary<string, AgentSessionStateBagValue>))]
[ExcludeFromCodeCoverage]
private sealed partial class JsonContext : JsonSerializerContext;
}
@@ -0,0 +1,113 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents attribution information for the source of an agent request message for a specific run, including the component type and
/// identifier.
/// </summary>
/// <remarks>
/// Use this struct to identify which component provided a message during an agent run.
/// This is useful to allow filtering of messages based on their source, such as distinguishing between user input, middleware-generated messages, and chat history.
/// </remarks>
public readonly struct AgentRequestMessageSourceAttribution : IEquatable<AgentRequestMessageSourceAttribution>
{
/// <summary>
/// Provides the key used in <see cref="ChatMessage.AdditionalProperties"/> to store the <see cref="AgentRequestMessageSourceAttribution"/>
/// associated with the agent request message.
/// </summary>
public static readonly string AdditionalPropertiesKey = "_attribution";
/// <summary>
/// Initializes a new instance of the <see cref="AgentRequestMessageSourceAttribution"/> struct with the specified source type and identifier.
/// </summary>
/// <param name="sourceType">The <see cref="AgentRequestMessageSourceType"/> of the component that provided the message.</param>
/// <param name="sourceId">The unique identifier of the component that provided the message.</param>
public AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType sourceType, string? sourceId)
{
this.SourceType = sourceType;
this.SourceId = sourceId;
}
/// <summary>
/// Gets the type of component that provided the message for the current agent run.
/// </summary>
public AgentRequestMessageSourceType SourceType { get; }
/// <summary>
/// Gets the unique identifier of the component that provided the message for the current agent run.
/// </summary>
public string? SourceId { get; }
/// <summary>
/// Determines whether the specified <see cref="AgentRequestMessageSourceAttribution"/> is equal to the current instance.
/// </summary>
/// <param name="other">The <see cref="AgentRequestMessageSourceAttribution"/> to compare with the current instance.</param>
/// <returns><see langword="true"/> if the specified instance is equal to the current instance; otherwise, <see langword="false"/>.</returns>
public bool Equals(AgentRequestMessageSourceAttribution other)
{
return this.SourceType == other.SourceType &&
string.Equals(this.SourceId, other.SourceId, StringComparison.Ordinal);
}
/// <summary>
/// Determines whether the specified object is equal to the current instance.
/// </summary>
/// <param name="obj">The object to compare with the current instance.</param>
/// <returns><see langword="true"/> if the specified object is equal to the current instance; otherwise, <see langword="false"/>.</returns>
public override bool Equals(object? obj)
{
return obj is AgentRequestMessageSourceAttribution other && this.Equals(other);
}
/// <summary>
/// Returns a string representation of the current instance.
/// </summary>
/// <returns>A string containing the source type and source identifier.</returns>
public override string ToString()
{
return this.SourceId is null
? $"{this.SourceType}"
: $"{this.SourceType}:{this.SourceId}";
}
/// <summary>
/// Returns a hash code for the current instance.
/// </summary>
/// <returns>A hash code for the current instance.</returns>
public override int GetHashCode()
{
unchecked
{
int hash = 17;
hash = (hash * 31) + this.SourceType.GetHashCode();
hash = (hash * 31) + (this.SourceId?.GetHashCode() ?? 0);
return hash;
}
}
/// <summary>
/// Determines whether two <see cref="AgentRequestMessageSourceAttribution"/> instances are equal.
/// </summary>
/// <param name="left">The first instance to compare.</param>
/// <param name="right">The second instance to compare.</param>
/// <returns><see langword="true"/> if the instances are equal; otherwise, <see langword="false"/>.</returns>
public static bool operator ==(AgentRequestMessageSourceAttribution left, AgentRequestMessageSourceAttribution right)
{
return left.Equals(right);
}
/// <summary>
/// Determines whether two <see cref="AgentRequestMessageSourceAttribution"/> instances are not equal.
/// </summary>
/// <param name="left">The first instance to compare.</param>
/// <param name="right">The second instance to compare.</param>
/// <returns><see langword="true"/> if the instances are not equal; otherwise, <see langword="false"/>.</returns>
public static bool operator !=(AgentRequestMessageSourceAttribution left, AgentRequestMessageSourceAttribution right)
{
return !left.Equals(right);
}
}
@@ -0,0 +1,91 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the source of an agent request message.
/// </summary>
/// <remarks>
/// Input messages for a specific agent run can originate from various sources.
/// This type helps to identify whether a message came from outside the agent pipeline,
/// whether it was produced by middleware, or came from chat history.
/// </remarks>
public readonly struct AgentRequestMessageSourceType : IEquatable<AgentRequestMessageSourceType>
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentRequestMessageSourceType"/> struct.
/// </summary>
/// <param name="value">The string value representing the source of the agent request message.</param>
public AgentRequestMessageSourceType(string value) => this.Value = Throw.IfNullOrWhitespace(value);
/// <summary>
/// Get the string value representing the source of the agent request message.
/// </summary>
public string Value { get { return field ?? External.Value; } }
/// <summary>
/// The message came from outside the agent pipeline (e.g., user input).
/// </summary>
public static AgentRequestMessageSourceType External { get; } = new AgentRequestMessageSourceType(nameof(External));
/// <summary>
/// The message was produced by middleware.
/// </summary>
public static AgentRequestMessageSourceType AIContextProvider { get; } = new AgentRequestMessageSourceType(nameof(AIContextProvider));
/// <summary>
/// The message came from chat history.
/// </summary>
public static AgentRequestMessageSourceType ChatHistory { get; } = new AgentRequestMessageSourceType(nameof(ChatHistory));
/// <summary>
/// Determines whether this instance and another specified <see cref="AgentRequestMessageSourceType"/> object have the same value.
/// </summary>
/// <param name="other">The <see cref="AgentRequestMessageSourceType"/> to compare to this instance.</param>
/// <returns><see langword="true"/> if the value of the <paramref name="other"/> parameter is the same as the value of this instance; otherwise, <see langword="false"/>.</returns>
public bool Equals(AgentRequestMessageSourceType other)
{
return string.Equals(this.Value, other.Value, StringComparison.Ordinal);
}
/// <summary>
/// Determines whether this instance and a specified object have the same value.
/// </summary>
/// <param name="obj">The object to compare to this instance.</param>
/// <returns><see langword="true"/> if <paramref name="obj"/> is a <see cref="AgentRequestMessageSourceType"/> and its value is the same as this instance; otherwise, <see langword="false"/>.</returns>
public override bool Equals(object? obj) => obj is AgentRequestMessageSourceType other && this.Equals(other);
/// <summary>
/// Returns the string representation of this instance.
/// </summary>
/// <returns>The string value representing the source of the agent request message.</returns>
public override string ToString() => this.Value;
/// <summary>
/// Returns the hash code for this instance.
/// </summary>
/// <returns>A 32-bit signed integer hash code.</returns>
public override int GetHashCode() => this.Value?.GetHashCode() ?? 0;
/// <summary>
/// Determines whether two specified <see cref="AgentRequestMessageSourceType"/> objects have the same value.
/// </summary>
/// <param name="left">The first <see cref="AgentRequestMessageSourceType"/> to compare.</param>
/// <param name="right">The second <see cref="AgentRequestMessageSourceType"/> to compare.</param>
/// <returns><see langword="true"/> if the value of <paramref name="left"/> is the same as the value of <paramref name="right"/>; otherwise, <see langword="false"/>.</returns>
public static bool operator ==(AgentRequestMessageSourceType left, AgentRequestMessageSourceType right)
{
return left.Equals(right);
}
/// <summary>
/// Determines whether two specified <see cref="AgentRequestMessageSourceType"/> objects have different values.
/// </summary>
/// <param name="left">The first <see cref="AgentRequestMessageSourceType"/> to compare.</param>
/// <param name="right">The second <see cref="AgentRequestMessageSourceType"/> to compare.</param>
/// <returns><see langword="true"/> if the value of <paramref name="left"/> is different from the value of <paramref name="right"/>; otherwise, <see langword="false"/>.</returns>
public static bool operator !=(AgentRequestMessageSourceType left, AgentRequestMessageSourceType right) => !(left == right);
}
@@ -0,0 +1,311 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the response to an <see cref="AIAgent"/> run request, containing messages and metadata about the interaction.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="AgentResponse"/> provides one or more response messages and metadata about the response.
/// A typical response will contain a single message, however a response may contain multiple messages
/// in a variety of scenarios. For example, if the agent internally invokes functions or tools, performs
/// RAG retrievals or has other complex logic, a single run by the agent may produce many messages showing
/// the intermediate progress that the agent made towards producing the agent result.
/// </para>
/// <para>
/// To get the text result of the response, use the <see cref="Text"/> property or simply call <see cref="ToString()"/> on the <see cref="AgentResponse"/>.
/// </para>
/// </remarks>
public class AgentResponse
{
/// <summary>The response messages.</summary>
private IList<ChatMessage>? _messages;
/// <summary>Initializes a new instance of the <see cref="AgentResponse"/> class.</summary>
public AgentResponse()
{
}
/// <summary>Initializes a new instance of the <see cref="AgentResponse"/> class.</summary>
/// <param name="message">The response message to include in this response.</param>
/// <exception cref="ArgumentNullException"><paramref name="message"/> is <see langword="null"/>.</exception>
public AgentResponse(ChatMessage message)
{
_ = Throw.IfNull(message);
this.Messages.Add(message);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponse"/> class from an existing <see cref="ChatResponse"/>.
/// </summary>
/// <param name="response">The <see cref="ChatResponse"/> from which to populate this <see cref="AgentResponse"/>.</param>
/// <exception cref="ArgumentNullException"><paramref name="response"/> is <see langword="null"/>.</exception>
/// <remarks>
/// This constructor creates an agent response that wraps an existing <see cref="ChatResponse"/>, preserving all
/// metadata and storing the original response in <see cref="RawRepresentation"/> for access to
/// the underlying implementation details.
/// </remarks>
public AgentResponse(ChatResponse response)
{
_ = Throw.IfNull(response);
this.AdditionalProperties = response.AdditionalProperties;
this.CreatedAt = response.CreatedAt;
this.FinishReason = response.FinishReason;
this.Messages = response.Messages;
this.RawRepresentation = response;
this.ResponseId = response.ResponseId;
this.Usage = response.Usage;
this.ContinuationToken = response.ContinuationToken;
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponse"/> class from an existing <see cref="AgentResponse"/>.
/// </summary>
/// <param name="response">The <see cref="AgentResponse"/> from which to copy properties.</param>
/// <exception cref="ArgumentNullException"><paramref name="response"/> is <see langword="null"/>.</exception>
/// <remarks>
/// This constructor creates a copy of an existing agent response, preserving all
/// metadata and storing the original response in <see cref="RawRepresentation"/> for access to
/// the underlying implementation details.
/// </remarks>
protected AgentResponse(AgentResponse response)
{
_ = Throw.IfNull(response);
this.AdditionalProperties = response.AdditionalProperties;
this.CreatedAt = response.CreatedAt;
this.FinishReason = response.FinishReason;
this.Messages = response.Messages;
this.RawRepresentation = response;
this.ResponseId = response.ResponseId;
this.Usage = response.Usage;
this.ContinuationToken = response.ContinuationToken;
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponse"/> class with the specified collection of messages.
/// </summary>
/// <param name="messages">The collection of response messages, or <see langword="null"/> to create an empty response.</param>
public AgentResponse(IList<ChatMessage>? messages)
{
this._messages = messages;
}
/// <summary>
/// Gets or sets the collection of messages to be represented by this response.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing the agent's response.
/// If the backing collection is <see langword="null"/>, accessing this property will create an empty list.
/// </value>
/// <remarks>
/// <para>
/// This property provides access to all messages generated during the agent's execution. While most
/// responses contain a single assistant message, complex agent behaviors may produce multiple messages
/// showing intermediate steps, function calls, or different types of content.
/// </para>
/// <para>
/// The collection is mutable and can be modified after creation. Setting this property to <see langword="null"/>
/// will cause subsequent access to return an empty list.
/// </para>
/// </remarks>
[AllowNull]
public IList<ChatMessage> Messages
{
get => this._messages ??= new List<ChatMessage>(1);
set => this._messages = value;
}
/// <summary>
/// Gets the concatenated text content of all messages in this response.
/// </summary>
/// <value>
/// A string containing the combined text from all <see cref="TextContent"/> instances
/// across all messages in <see cref="Messages"/>, or an empty string if no text content is present.
/// </value>
/// <remarks>
/// This property provides a convenient way to access the textual response without needing to
/// iterate through individual messages and content items. Non-text content is ignored.
/// </remarks>
[JsonIgnore]
public string Text => this._messages?.ConcatText() ?? string.Empty;
/// <summary>
/// Gets or sets the identifier of the agent that generated this response.
/// </summary>
/// <value>
/// A unique string identifier for the agent, or <see langword="null"/> if not specified.
/// </value>
/// <remarks>
/// This identifier helps track which agent generated the response in multi-agent scenarios
/// or for debugging and telemetry purposes.
/// </remarks>
public string? AgentId { get; set; }
/// <summary>
/// Gets or sets the unique identifier for this specific response.
/// </summary>
/// <value>
/// A unique string identifier for this response instance, or <see langword="null"/> if not assigned.
/// </value>
public string? ResponseId { get; set; }
/// <summary>
/// Gets or sets the continuation token for getting the result of a background agent response.
/// </summary>
/// <remarks>
/// <see cref="AIAgent"/> implementations that support background responses will return
/// a continuation token if background responses are allowed in <see cref="AgentRunOptions.AllowBackgroundResponses"/>
/// and the result of the response has not been obtained yet. If the response has completed and the result has been obtained,
/// the token will be <see langword="null"/>.
/// <para>
/// This property should be used in conjunction with <see cref="AgentRunOptions.ContinuationToken"/> to
/// continue to poll for the completion of the response. Pass this token to
/// <see cref="AgentRunOptions.ContinuationToken"/> on subsequent calls to <see cref="AIAgent.RunAsync(AgentSession?, AgentRunOptions?, System.Threading.CancellationToken)"/>
/// to poll for completion.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public ResponseContinuationToken? ContinuationToken { get; set; }
/// <summary>
/// Gets or sets the timestamp indicating when this response was created.
/// </summary>
/// <value>
/// A <see cref="DateTimeOffset"/> representing when the response was generated,
/// or <see langword="null"/> if not specified.
/// </value>
/// <remarks>
/// The creation timestamp is useful for auditing, logging, and understanding
/// the chronology of agentic interactions.
/// </remarks>
public DateTimeOffset? CreatedAt { get; set; }
/// <summary>
/// Gets or sets the reason for the agent response finishing.
/// </summary>
/// <value>
/// A <see cref="ChatFinishReason"/> value indicating why the response finished (e.g., stop, length, content filter, tool calls),
/// or <see langword="null"/> if the finish reason is not available.
/// </value>
/// <remarks>
/// <para>
/// This property is particularly useful for detecting non-normal completions, such as content filtering
/// or token limit truncation, which may require special handling by the caller.
/// </para>
/// </remarks>
public ChatFinishReason? FinishReason { get; set; }
/// <summary>
/// Gets or sets the resource usage information for generating this response.
/// </summary>
/// <value>
/// A <see cref="UsageDetails"/> instance containing token counts and other usage metrics,
/// or <see langword="null"/> if usage information is not available.
/// </value>
public UsageDetails? Usage { get; set; }
/// <summary>Gets or sets the raw representation of the run response from an underlying implementation.</summary>
/// <remarks>
/// If a <see cref="AgentResponse"/> is created to represent some underlying object from another object
/// model, this property can be used to store that original object. This can be useful for debugging or
/// for enabling a consumer to access the underlying object model if needed.
/// </remarks>
[JsonIgnore]
public object? RawRepresentation { get; set; }
/// <summary>
/// Gets or sets additional properties associated with this response.
/// </summary>
/// <value>
/// An <see cref="AdditionalPropertiesDictionary"/> containing custom properties,
/// or <see langword="null"/> if no additional properties are present.
/// </value>
/// <remarks>
/// Additional properties provide a way to include custom metadata or provider-specific
/// information that doesn't fit into the standard response schema. This is useful for
/// preserving implementation-specific details or extending the response with custom data.
/// </remarks>
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
/// <inheritdoc />
public override string ToString() => this.Text;
/// <summary>
/// Converts this <see cref="AgentResponse"/> into a collection of <see cref="AgentResponseUpdate"/> instances
/// suitable for streaming scenarios.
/// </summary>
/// <returns>
/// An array of <see cref="AgentResponseUpdate"/> instances that collectively represent
/// the same information as this response.
/// </returns>
/// <remarks>
/// <para>
/// This method is useful for converting complete responses back into streaming format,
/// which may be needed for scenarios that require uniform handling of both streaming
/// and non-streaming agent responses.
/// </para>
/// <para>
/// Each message in <see cref="Messages"/> becomes a separate update, and usage information
/// is included as an additional update if present. The order of updates preserves the
/// original message sequence.
/// </para>
/// </remarks>
public AgentResponseUpdate[] ToAgentResponseUpdates()
{
AgentResponseUpdate? extra = null;
if (this.AdditionalProperties is not null || this.Usage is not null)
{
extra = new AgentResponseUpdate
{
AdditionalProperties = this.AdditionalProperties,
};
if (this.Usage is { } usage)
{
extra.Contents.Add(new UsageContent(usage));
}
}
int messageCount = this._messages?.Count ?? 0;
var updates = new AgentResponseUpdate[messageCount + (extra is not null ? 1 : 0)];
int i;
for (i = 0; i < messageCount; i++)
{
ChatMessage message = this._messages![i];
updates[i] = new AgentResponseUpdate
{
AdditionalProperties = message.AdditionalProperties,
AuthorName = message.AuthorName,
Contents = message.Contents,
RawRepresentation = message.RawRepresentation,
Role = message.Role,
FinishReason = this.FinishReason,
AgentId = this.AgentId,
ResponseId = this.ResponseId,
MessageId = message.MessageId,
CreatedAt = message.CreatedAt ?? this.CreatedAt,
};
}
if (extra is not null)
{
updates[i] = extra;
}
return updates;
}
}
@@ -0,0 +1,213 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides extension methods for working with <see cref="AgentResponse"/> and <see cref="AgentResponseUpdate"/> instances.
/// </summary>
public static class AgentResponseExtensions
{
/// <summary>
/// Creates a <see cref="ChatResponse"/> from an <see cref="AgentResponse"/> instance.
/// </summary>
/// <param name="response">The <see cref="AgentResponse"/> to convert.</param>
/// <returns>A <see cref="ChatResponse"/> built from the specified <paramref name="response"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="response"/> is <see langword="null"/>.</exception>
/// <remarks>
/// If the <paramref name="response"/>'s <see cref="AgentResponse.RawRepresentation"/> is already a
/// <see cref="ChatResponse"/> instance, that instance is returned directly.
/// Otherwise, a new <see cref="ChatResponse"/> is created and populated with the data from the <paramref name="response"/>.
/// The resulting instance is a shallow copy; any reference-type members (e.g. <see cref="AgentResponse.Messages"/>)
/// will be shared between the two instances.
/// </remarks>
public static ChatResponse AsChatResponse(this AgentResponse response)
{
Throw.IfNull(response);
return
response.RawRepresentation as ChatResponse ??
new()
{
AdditionalProperties = response.AdditionalProperties,
CreatedAt = response.CreatedAt,
FinishReason = response.FinishReason,
Messages = response.Messages,
RawRepresentation = response,
ResponseId = response.ResponseId,
Usage = response.Usage,
ContinuationToken = response.ContinuationToken,
};
}
/// <summary>
/// Creates a <see cref="ChatResponseUpdate"/> from an <see cref="AgentResponseUpdate"/> instance.
/// </summary>
/// <param name="responseUpdate">The <see cref="AgentResponseUpdate"/> to convert.</param>
/// <returns>A <see cref="ChatResponseUpdate"/> built from the specified <paramref name="responseUpdate"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="responseUpdate"/> is <see langword="null"/>.</exception>
/// <remarks>
/// If the <paramref name="responseUpdate"/>'s <see cref="AgentResponseUpdate.RawRepresentation"/> is already a
/// <see cref="ChatResponseUpdate"/> instance, that instance is returned directly.
/// Otherwise, a new <see cref="ChatResponseUpdate"/> is created and populated with the data from the <paramref name="responseUpdate"/>.
/// The resulting instance is a shallow copy; any reference-type members (e.g. <see cref="AgentResponseUpdate.Contents"/>)
/// will be shared between the two instances.
/// </remarks>
public static ChatResponseUpdate AsChatResponseUpdate(this AgentResponseUpdate responseUpdate)
{
Throw.IfNull(responseUpdate);
return
responseUpdate.RawRepresentation as ChatResponseUpdate ??
new()
{
AdditionalProperties = responseUpdate.AdditionalProperties,
AuthorName = responseUpdate.AuthorName,
Contents = responseUpdate.Contents,
CreatedAt = responseUpdate.CreatedAt,
FinishReason = responseUpdate.FinishReason,
MessageId = responseUpdate.MessageId,
RawRepresentation = responseUpdate,
ResponseId = responseUpdate.ResponseId,
Role = responseUpdate.Role,
ContinuationToken = responseUpdate.ContinuationToken,
};
}
/// <summary>
/// Creates an asynchronous enumerable of <see cref="ChatResponseUpdate"/> instances from an asynchronous
/// enumerable of <see cref="AgentResponseUpdate"/> instances.
/// </summary>
/// <param name="responseUpdates">The sequence of <see cref="AgentResponseUpdate"/> instances to convert.</param>
/// <returns>An asynchronous enumerable of <see cref="ChatResponseUpdate"/> instances built from <paramref name="responseUpdates"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="responseUpdates"/> is <see langword="null"/>.</exception>
/// <remarks>
/// Each <see cref="AgentResponseUpdate"/> is converted to a <see cref="ChatResponseUpdate"/> using
/// <see cref="AsChatResponseUpdate"/>.
/// </remarks>
public static async IAsyncEnumerable<ChatResponseUpdate> AsChatResponseUpdatesAsync(
this IAsyncEnumerable<AgentResponseUpdate> responseUpdates)
{
Throw.IfNull(responseUpdates);
await foreach (var responseUpdate in responseUpdates.ConfigureAwait(false))
{
yield return responseUpdate.AsChatResponseUpdate();
}
}
/// <summary>
/// Combines a sequence of <see cref="AgentResponseUpdate"/> instances into a single <see cref="AgentResponse"/>.
/// </summary>
/// <param name="updates">The sequence of updates to be combined into a single response.</param>
/// <returns>A single <see cref="AgentResponse"/> that represents the combined state of all the updates.</returns>
/// <exception cref="ArgumentNullException"><paramref name="updates"/> is <see langword="null"/>.</exception>
/// <remarks>
/// As part of combining <paramref name="updates"/> into a single <see cref="AgentResponse"/>, the method will attempt to reconstruct
/// <see cref="ChatMessage"/> instances. This includes using <see cref="AgentResponseUpdate.MessageId"/> to determine
/// message boundaries, as well as coalescing contiguous <see cref="AIContent"/> items where applicable, e.g. multiple
/// <see cref="TextContent"/> instances in a row may be combined into a single <see cref="TextContent"/>.
/// </remarks>
public static AgentResponse ToAgentResponse(
this IEnumerable<AgentResponseUpdate> updates)
{
_ = Throw.IfNull(updates);
AgentResponseDetails additionalDetails = new();
ChatResponse chatResponse =
AsChatResponseUpdatesWithAdditionalDetails(updates, additionalDetails)
.ToChatResponse();
return new AgentResponse(chatResponse)
{
AgentId = additionalDetails.AgentId,
};
}
/// <summary>
/// Asynchronously combines a sequence of <see cref="AgentResponseUpdate"/> instances into a single <see cref="AgentResponse"/>.
/// </summary>
/// <param name="updates">The asynchronous sequence of updates to be combined into a single response.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains a single <see cref="AgentResponse"/> that represents the combined state of all the updates.</returns>
/// <exception cref="ArgumentNullException"><paramref name="updates"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// This is the asynchronous version of <see cref="ToAgentResponse(IEnumerable{AgentResponseUpdate})"/>.
/// It performs the same combining logic but operates on an asynchronous enumerable of updates.
/// </para>
/// <para>
/// As part of combining <paramref name="updates"/> into a single <see cref="AgentResponse"/>, the method will attempt to reconstruct
/// <see cref="ChatMessage"/> instances. This includes using <see cref="AgentResponseUpdate.MessageId"/> to determine
/// message boundaries, as well as coalescing contiguous <see cref="AIContent"/> items where applicable, e.g. multiple
/// <see cref="TextContent"/> instances in a row may be combined into a single <see cref="TextContent"/>.
/// </para>
/// </remarks>
public static Task<AgentResponse> ToAgentResponseAsync(
this IAsyncEnumerable<AgentResponseUpdate> updates,
CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(updates);
return ToAgentResponseAsync(updates, cancellationToken);
static async Task<AgentResponse> ToAgentResponseAsync(
IAsyncEnumerable<AgentResponseUpdate> updates,
CancellationToken cancellationToken)
{
AgentResponseDetails additionalDetails = new();
ChatResponse chatResponse = await
AsChatResponseUpdatesWithAdditionalDetailsAsync(updates, additionalDetails, cancellationToken)
.ToChatResponseAsync(cancellationToken)
.ConfigureAwait(false);
return new AgentResponse(chatResponse)
{
AgentId = additionalDetails.AgentId,
};
}
}
private static IEnumerable<ChatResponseUpdate> AsChatResponseUpdatesWithAdditionalDetails(
IEnumerable<AgentResponseUpdate> updates,
AgentResponseDetails additionalDetails)
{
foreach (var update in updates)
{
UpdateAdditionalDetails(update, additionalDetails);
yield return update.AsChatResponseUpdate();
}
}
private static async IAsyncEnumerable<ChatResponseUpdate> AsChatResponseUpdatesWithAdditionalDetailsAsync(
IAsyncEnumerable<AgentResponseUpdate> updates,
AgentResponseDetails additionalDetails,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
{
UpdateAdditionalDetails(update, additionalDetails);
yield return update.AsChatResponseUpdate();
}
}
private static void UpdateAdditionalDetails(AgentResponseUpdate update, AgentResponseDetails details)
{
if (update.AgentId is { Length: > 0 })
{
details.AgentId = update.AgentId;
}
}
private sealed class AgentResponseDetails
{
public string? AgentId { get; set; }
}
}
@@ -0,0 +1,178 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a single streaming response chunk from an <see cref="AIAgent"/>.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="AgentResponseUpdate"/> is so named because it represents updates
/// that layer on each other to form a single agent response. Conceptually, this combines the roles of
/// <see cref="AgentResponse"/> and <see cref="ChatMessage"/> in streaming output.
/// </para>
/// <para>
/// To get the text result of this response chunk, use the <see cref="Text"/> property or simply call <see cref="ToString()"/> on the <see cref="AgentResponseUpdate"/>.
/// </para>
/// <para>
/// The relationship between <see cref="AgentResponse"/> and <see cref="AgentResponseUpdate"/> is
/// codified in the <see cref="AgentResponseExtensions.ToAgentResponseAsync"/> and
/// <see cref="AgentResponse.ToAgentResponseUpdates"/>, which enable bidirectional conversions
/// between the two. Note, however, that the provided conversions may be lossy, for example if multiple
/// updates all have different <see cref="RawRepresentation"/> objects whereas there's only one slot for
/// such an object available in <see cref="AgentResponse.RawRepresentation"/>.
/// </para>
/// </remarks>
[DebuggerDisplay("[{Role}] {ContentForDebuggerDisplay}{EllipsesForDebuggerDisplay,nq}")]
public class AgentResponseUpdate
{
/// <summary>The response update content items.</summary>
private IList<AIContent>? _contents;
/// <summary>Initializes a new instance of the <see cref="AgentResponseUpdate"/> class.</summary>
[JsonConstructor]
public AgentResponseUpdate()
{
}
/// <summary>Initializes a new instance of the <see cref="AgentResponseUpdate"/> class.</summary>
/// <param name="role">The role of the author of the update.</param>
/// <param name="content">The text content of the update.</param>
public AgentResponseUpdate(ChatRole? role, string? content)
: this(role, content is null ? null : [new TextContent(content)])
{
}
/// <summary>Initializes a new instance of the <see cref="AgentResponseUpdate"/> class.</summary>
/// <param name="role">The role of the author of the update.</param>
/// <param name="contents">The contents of the update.</param>
public AgentResponseUpdate(ChatRole? role, IList<AIContent>? contents)
{
this.Role = role;
this._contents = contents;
}
/// <summary>Initializes a new instance of the <see cref="AgentResponseUpdate"/> class.</summary>
/// <param name="chatResponseUpdate">The <see cref="ChatResponseUpdate"/> from which to seed this <see cref="AgentResponseUpdate"/>.</param>
public AgentResponseUpdate(ChatResponseUpdate chatResponseUpdate)
{
_ = Throw.IfNull(chatResponseUpdate);
this.AdditionalProperties = chatResponseUpdate.AdditionalProperties;
this.AuthorName = chatResponseUpdate.AuthorName;
this.Contents = chatResponseUpdate.Contents;
this.CreatedAt = chatResponseUpdate.CreatedAt;
this.FinishReason = chatResponseUpdate.FinishReason;
this.MessageId = chatResponseUpdate.MessageId;
this.RawRepresentation = chatResponseUpdate;
this.ResponseId = chatResponseUpdate.ResponseId;
this.Role = chatResponseUpdate.Role;
this.ContinuationToken = chatResponseUpdate.ContinuationToken;
}
/// <summary>Gets or sets the name of the author of the response update.</summary>
public string? AuthorName
{
get => field;
set => field = string.IsNullOrWhiteSpace(value) ? null : value;
}
/// <summary>Gets or sets the role of the author of the response update.</summary>
public ChatRole? Role { get; set; }
/// <summary>Gets the text of this update.</summary>
/// <remarks>
/// This property concatenates the text of all <see cref="TextContent"/> objects in <see cref="Contents"/>.
/// </remarks>
[JsonIgnore]
public string Text => this._contents is not null ? this._contents.ConcatText() : string.Empty;
/// <summary>Gets or sets the agent run response update content items.</summary>
[AllowNull]
public IList<AIContent> Contents
{
get => this._contents ??= [];
set => this._contents = value;
}
/// <summary>Gets or sets the raw representation of the response update from an underlying implementation.</summary>
/// <remarks>
/// If a <see cref="AgentResponseUpdate"/> is created to represent some underlying object from another object
/// model, this property can be used to store that original object. This can be useful for debugging or
/// for enabling a consumer to access the underlying object model if needed.
/// </remarks>
[JsonIgnore]
public object? RawRepresentation { get; set; }
/// <summary>Gets or sets additional properties for the update.</summary>
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
/// <summary>Gets or sets the ID of the agent that produced the response.</summary>
public string? AgentId { get; set; }
/// <summary>Gets or sets the ID of the response of which this update is a part.</summary>
public string? ResponseId { get; set; }
/// <summary>Gets or sets the ID of the message of which this update is a part.</summary>
/// <remarks>
/// A single streaming response may be composed of multiple messages, each of which may be represented
/// by multiple updates. This property is used to group those updates together into messages.
///
/// Some providers may consider streaming responses to be a single message, and in that case
/// the value of this property may be the same as the response ID.
///
/// This value is used when <see cref="AgentResponseExtensions.ToAgentResponseAsync(IAsyncEnumerable{AgentResponseUpdate}, System.Threading.CancellationToken)"/>
/// groups <see cref="AgentResponseUpdate"/> instances into <see cref="AgentResponse"/> instances.
/// The value must be unique to each call to the underlying provider, and must be shared by
/// all updates that are part of the same logical message within a streaming response.
/// </remarks>
public string? MessageId { get; set; }
/// <summary>Gets or sets a timestamp for the response update.</summary>
public DateTimeOffset? CreatedAt { get; set; }
/// <summary>
/// Gets or sets the continuation token for resuming the streamed agent response of which this update is a part.
/// </summary>
/// <remarks>
/// <see cref="AIAgent"/> implementations that support background responses will return
/// a continuation token on each update if background responses are allowed in <see cref="AgentRunOptions.AllowBackgroundResponses"/>
/// except for the last update, for which the token will be <see langword="null"/>.
/// <para>
/// This property should be used for stream resumption, where the continuation token of the latest received update should be
/// passed to <see cref="AgentRunOptions.ContinuationToken"/> on subsequent calls to <see cref="AIAgent.RunStreamingAsync(AgentSession?, AgentRunOptions?, System.Threading.CancellationToken)"/>
/// to resume streaming from the point of interruption.
/// </para>
/// </remarks>
public ResponseContinuationToken? ContinuationToken { get; set; }
/// <summary>
/// Gets or sets the reason for the agent response finishing.
/// </summary>
/// <value>
/// A <see cref="ChatFinishReason"/> value indicating why the response finished (e.g., stop, length, content filter, tool calls),
/// or <see langword="null"/> if the finish reason is not available or not yet determined (mid-stream).
/// </value>
public ChatFinishReason? FinishReason { get; set; }
/// <inheritdoc/>
public override string ToString() => this.Text;
/// <summary>Gets a <see cref="AIContent"/> object to display in the debugger display.</summary>
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
[ExcludeFromCodeCoverage]
private AIContent? ContentForDebuggerDisplay => this._contents is { Count: > 0 } ? this._contents[0] : null;
/// <summary>Gets an indication for the debugger display of whether there's more content.</summary>
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
[ExcludeFromCodeCoverage]
private string EllipsesForDebuggerDisplay => this._contents is { Count: > 1 } ? ", ..." : string.Empty;
}
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
#if NET
using System.Buffers;
#endif
#if NET
using System.Text;
#endif
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents the response of the specified type <typeparamref name="T"/> to an <see cref="AIAgent"/> run request.
/// </summary>
/// <typeparam name="T">The type of value expected from the agent.</typeparam>
public class AgentResponse<T> : AgentResponse
{
private readonly JsonSerializerOptions _serializerOptions;
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponse{T}"/> class.
/// </summary>
/// <param name="response">The <see cref="AgentResponse"/> from which to populate this <see cref="AgentResponse{T}"/>.</param>
/// <param name="serializerOptions">The <see cref="JsonSerializerOptions"/> to use when deserializing the result.</param>
/// <exception cref="ArgumentNullException"><paramref name="serializerOptions"/> is <see langword="null"/>.</exception>
public AgentResponse(AgentResponse response, JsonSerializerOptions serializerOptions) : base(response)
{
_ = Throw.IfNull(serializerOptions);
this._serializerOptions = serializerOptions;
}
/// <summary>
/// Gets or sets a value indicating whether the JSON schema has an extra object wrapper.
/// </summary>
/// <remarks>
/// The wrapper is required for any non-JSON-object-typed values such as numbers, enum values, and arrays.
/// </remarks>
public bool IsWrappedInObject { get; init; }
/// <summary>
/// Gets the result value of the agent response as an instance of <typeparamref name="T"/>.
/// </summary>
[JsonIgnore]
public virtual T Result
{
get
{
var json = this.Text;
if (string.IsNullOrEmpty(json))
{
throw new InvalidOperationException("The response did not contain JSON to be deserialized.");
}
if (this.IsWrappedInObject)
{
json = StructuredOutputSchemaUtilities.UnwrapResponseData(json!);
}
T? deserialized = DeserializeFirstTopLevelObject(json!, (JsonTypeInfo<T>)this._serializerOptions.GetTypeInfo(typeof(T)));
if (deserialized is null)
{
throw new InvalidOperationException("The deserialized response is null.");
}
return deserialized;
}
}
private static T? DeserializeFirstTopLevelObject(string json, JsonTypeInfo<T> typeInfo)
{
#if NET
// We need to deserialize only the first top-level object as a workaround for a common LLM backend
// issue. GPT 3.5 Turbo commonly returns multiple top-level objects after doing a function call.
// See https://community.openai.com/t/2-json-objects-returned-when-using-function-calling-and-json-mode/574348
var utf8ByteLength = Encoding.UTF8.GetByteCount(json);
var buffer = ArrayPool<byte>.Shared.Rent(utf8ByteLength);
try
{
var utf8SpanLength = Encoding.UTF8.GetBytes(json, 0, json.Length, buffer, 0);
var reader = new Utf8JsonReader(new ReadOnlySpan<byte>(buffer, 0, utf8SpanLength), new() { AllowMultipleValues = true });
return JsonSerializer.Deserialize(ref reader, typeInfo);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
#else
return JsonSerializer.Deserialize(json, typeInfo);
#endif
}
}
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>Provides context for an in-flight agent run.</summary>
public sealed class AgentRunContext
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentRunContext"/> class.
/// </summary>
/// <param name="agent">The <see cref="AIAgent"/> that is executing the current run.</param>
/// <param name="session">The <see cref="AgentSession"/> that is associated with the current run if any.</param>
/// <param name="requestMessages">The request messages passed into the current run.</param>
/// <param name="agentRunOptions">The <see cref="AgentRunOptions"/> that was passed to the current run.</param>
public AgentRunContext(
AIAgent agent,
AgentSession? session,
IReadOnlyCollection<ChatMessage> requestMessages,
AgentRunOptions? agentRunOptions)
{
this.Agent = Throw.IfNull(agent);
this.Session = session;
this.RequestMessages = Throw.IfNull(requestMessages);
this.RunOptions = agentRunOptions;
}
/// <summary>Gets the <see cref="AIAgent"/> that is executing the current run.</summary>
public AIAgent Agent { get; }
/// <summary>Gets the <see cref="AgentSession"/> that is associated with the current run.</summary>
public AgentSession? Session { get; }
/// <summary>Gets the request messages passed into the current run.</summary>
public IReadOnlyCollection<ChatMessage> RequestMessages { get; }
/// <summary>Gets the <see cref="AgentRunOptions"/> that was passed to the current run.</summary>
public AgentRunOptions? RunOptions { get; }
}
@@ -0,0 +1,128 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides optional parameters and configuration settings for controlling agent run behavior.
/// </summary>
/// <remarks>
/// <para>
/// Implementations of <see cref="AIAgent"/> may provide subclasses of <see cref="AgentRunOptions"/> with additional options specific to that agent type.
/// </para>
/// </remarks>
public class AgentRunOptions
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentRunOptions"/> class.
/// </summary>
public AgentRunOptions()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentRunOptions"/> class by copying values from the specified options.
/// </summary>
/// <param name="options">The options instance from which to copy values.</param>
/// <exception cref="ArgumentNullException"><paramref name="options"/> is <see langword="null"/>.</exception>
protected AgentRunOptions(AgentRunOptions options)
{
_ = Throw.IfNull(options);
this.ContinuationToken = options.ContinuationToken;
this.AllowBackgroundResponses = options.AllowBackgroundResponses;
this.AdditionalProperties = options.AdditionalProperties?.Clone();
this.ResponseFormat = options.ResponseFormat;
}
/// <summary>
/// Gets or sets the continuation token for resuming and getting the result of the agent response identified by this token.
/// </summary>
/// <remarks>
/// This property is used for background responses that can be activated via the <see cref="AllowBackgroundResponses"/>
/// property if the <see cref="AIAgent"/> implementation supports them.
/// Streamed background responses, such as those returned by default by <see cref="AIAgent.RunStreamingAsync(AgentSession?, AgentRunOptions?, System.Threading.CancellationToken)"/>
/// can be resumed if interrupted. This means that a continuation token obtained from the <see cref="AgentResponseUpdate.ContinuationToken"/>
/// of an update just before the interruption occurred can be passed to this property to resume the stream from the point of interruption.
/// Non-streamed background responses, such as those returned by <see cref="AIAgent.RunAsync(AgentSession?, AgentRunOptions?, System.Threading.CancellationToken)"/>,
/// can be polled for completion by obtaining the token from the <see cref="AgentResponse.ContinuationToken"/> property
/// and passing it via this property on subsequent calls to <see cref="AIAgent.RunAsync(AgentSession?, AgentRunOptions?, System.Threading.CancellationToken)"/>.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public ResponseContinuationToken? ContinuationToken { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the background responses are allowed.
/// </summary>
/// <remarks>
/// <para>
/// Background responses allow running long-running operations or tasks asynchronously in the background that can be resumed by streaming APIs
/// and polled for completion by non-streaming APIs.
/// </para>
/// <para>
/// When this property is set to true, non-streaming APIs may start a background operation and return an initial
/// response with a continuation token. Subsequent calls to the same API should be made in a polling manner with
/// the continuation token to get the final result of the operation.
/// </para>
/// <para>
/// When this property is set to true, streaming APIs may also start a background operation and begin streaming
/// response updates until the operation is completed. If the streaming connection is interrupted, the
/// continuation token obtained from the last update that has one should be supplied to a subsequent call to the same streaming API
/// to resume the stream from the point of interruption and continue receiving updates until the operation is completed.
/// </para>
/// <para>
/// This property only takes effect if the implementation it's used with supports background responses.
/// If the implementation does not support background responses, this property will be ignored.
/// </para>
/// </remarks>
public bool? AllowBackgroundResponses { get; set; }
/// <summary>
/// Gets or sets additional properties associated with these options.
/// </summary>
/// <value>
/// An <see cref="AdditionalPropertiesDictionary"/> containing custom properties,
/// or <see langword="null"/> if no additional properties are present.
/// </value>
/// <remarks>
/// Additional properties provide a way to include custom metadata or provider-specific
/// information that doesn't fit into the standard options schema. This is useful for
/// preserving implementation-specific details or extending the options with custom data.
/// </remarks>
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
/// <summary>
/// Gets or sets the response format.
/// </summary>
/// <remarks>
/// If <see langword="null"/>, no response format is specified and the agent will use its default.
/// This property can be set to <see cref="ChatResponseFormat.Text"/> to specify that the response should be unstructured text,
/// to <see cref="ChatResponseFormat.Json"/> to specify that the response should be structured JSON data, or
/// an instance of <see cref="ChatResponseFormatJson"/> constructed with a specific JSON schema to request that the
/// response be structured JSON data according to that schema. It is up to the agent implementation if or how
/// to honor the request. If the agent implementation doesn't recognize the specific kind of <see cref="ChatResponseFormat"/>,
/// it can be ignored.
/// </remarks>
public ChatResponseFormat? ResponseFormat { get; set; }
/// <summary>
/// Produces a clone of the current <see cref="AgentRunOptions"/> instance.
/// </summary>
/// <returns>
/// A clone of the current <see cref="AgentRunOptions"/> instance.
/// </returns>
/// <remarks>
/// <para>
/// The clone will have the same values for all properties as the original instance. Any collections, like <see cref="AdditionalProperties"/>,
/// are shallow-cloned, meaning a new collection instance is created, but any references contained by the collections are shared with the original.
/// </para>
/// <para>
/// Derived types should override <see cref="Clone"/> to return an instance of the derived type.
/// </para>
/// </remarks>
public virtual AgentRunOptions Clone() => new(this);
}
@@ -0,0 +1,119 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Base abstraction for all agent threads.
/// </summary>
/// <remarks>
/// <para>
/// An <see cref="AgentSession"/> contains the state of a specific conversation with an agent which may include:
/// <list type="bullet">
/// <item><description>Conversation history or a reference to externally stored conversation history.</description></item>
/// <item><description>Memories or a reference to externally stored memories.</description></item>
/// <item><description>Any other state that the agent needs to persist across runs for a conversation.</description></item>
/// </list>
/// </para>
/// <para>
/// An <see cref="AgentSession"/> may also have behaviors attached to it that may include:
/// <list type="bullet">
/// <item><description>Customized storage of state.</description></item>
/// <item><description>Data extraction from and injection into a conversation.</description></item>
/// <item><description>Chat history reduction, e.g. where messages needs to be summarized or truncated to reduce the size.</description></item>
/// </list>
/// An <see cref="AgentSession"/> is always constructed by an <see cref="AIAgent"/> so that the <see cref="AIAgent"/>
/// can attach any necessary behaviors to the <see cref="AgentSession"/>. See the <see cref="AIAgent.CreateSessionAsync(System.Threading.CancellationToken)"/>
/// and <see cref="AIAgent.DeserializeSessionAsync(JsonElement, JsonSerializerOptions?, System.Threading.CancellationToken)"/> methods for more information.
/// </para>
/// <para>
/// Because of these behaviors, an <see cref="AgentSession"/> may not be reusable across different agents, since each agent
/// may add different behaviors to the <see cref="AgentSession"/> it creates.
/// </para>
/// <para>
/// To support conversations that may need to survive application restarts or separate service requests, an <see cref="AgentSession"/> can be serialized
/// and deserialized, so that it can be saved in a persistent store.
/// The <see cref="AIAgent"/> provides the <see cref="AIAgent.SerializeSessionAsync(AgentSession, JsonSerializerOptions?, System.Threading.CancellationToken)"/> method to serialize the session to a
/// <see cref="JsonElement"/> and the <see cref="AIAgent.DeserializeSessionAsync(JsonElement, JsonSerializerOptions?, System.Threading.CancellationToken)"/> method
/// can be used to deserialize the session.
/// </para>
/// <para>
/// <strong>Security considerations:</strong> Serialized sessions may contain conversation content, session identifiers,
/// and other potentially sensitive data including PII. Developers should:
/// <list type="bullet">
/// <item><description>Treat serialized session data as sensitive and store it securely with appropriate access controls and encryption at rest.</description></item>
/// <item><description>Treat restoring a session from an untrusted source as equivalent to accepting untrusted input. A compromised storage backend
/// could alter message roles to escalate trust, or inject adversarial content that influences LLM behavior.</description></item>
/// </list>
/// </para>
/// </remarks>
/// <seealso cref="AIAgent"/>
/// <seealso cref="AIAgent.CreateSessionAsync(System.Threading.CancellationToken)"/>
/// <seealso cref="AIAgent.DeserializeSessionAsync(JsonElement, JsonSerializerOptions?, System.Threading.CancellationToken)"/>
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public abstract class AgentSession
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentSession"/> class.
/// </summary>
protected AgentSession()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSession"/> class.
/// </summary>
protected AgentSession(AgentSessionStateBag stateBag)
{
this.StateBag = Throw.IfNull(stateBag);
}
/// <summary>
/// Gets any arbitrary state associated with this session.
/// </summary>
/// <remarks>
/// Data stored in the <see cref="StateBag"/> will be included when the session is serialized.
/// Avoid storing secrets, credentials, or highly sensitive data in the state bag without appropriate encryption,
/// as this data may be persisted to external storage.
/// </remarks>
[JsonPropertyName("stateBag")]
public AgentSessionStateBag StateBag { get; protected set; } = new();
/// <summary>Asks the <see cref="AgentSession"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
/// <param name="serviceType">The type of object being requested.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="serviceType"/> is <see langword="null"/>.</exception>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the <see cref="AgentSession"/>,
/// including itself or any services it might be wrapping. For example, to access a <see cref="ChatHistoryProvider"/> if available for the instance,
/// <see cref="GetService"/> may be used to request it.
/// </remarks>
public virtual object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);
return serviceKey is null && serviceType.IsInstanceOfType(this)
? this
: null;
}
/// <summary>Asks the <see cref="AgentSession"/> for an object of type <typeparamref name="TService"/>.</summary>
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the <see cref="AgentSession"/>,
/// including itself or any services it might be wrapping.
/// </remarks>
public TService? GetService<TService>(object? serviceKey = null)
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay => $"StateBag Count = {this.StateBag.Count}";
}
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides extension methods for <see cref="AgentSession"/>.
/// </summary>
public static class AgentSessionExtensions
{
/// <summary>
/// Attempts to retrieve the in-memory chat history messages associated with the specified agent session, if the agent is storing memories in the session using the <see cref="InMemoryChatHistoryProvider"/>
/// </summary>
/// <remarks>
/// This method is only applicable when using <see cref="InMemoryChatHistoryProvider"/> and if the service does not require in-service chat history storage.
/// </remarks>
/// <param name="session">The agent session from which to retrieve in-memory chat history.</param>
/// <param name="messages">When this method returns, contains the list of chat history messages if available; otherwise, null.</param>
/// <param name="stateKey">An optional key used to identify the chat history state in the session's state bag. If null, the default key for
/// in-memory chat history is used.</param>
/// <param name="jsonSerializerOptions">Optional JSON serializer options to use when accessing the session state. If null, default options are used.</param>
/// <returns><see langword="true"/> if the in-memory chat history messages were found and retrieved; <see langword="false"/> otherwise.</returns>
public static bool TryGetInMemoryChatHistory(this AgentSession session, [MaybeNullWhen(false)] out List<ChatMessage> messages, string? stateKey = null, JsonSerializerOptions? jsonSerializerOptions = null)
{
_ = Throw.IfNull(session);
if (session.StateBag.TryGetValue(stateKey ?? nameof(InMemoryChatHistoryProvider), out InMemoryChatHistoryProvider.State? state, jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions) && state?.Messages is not null)
{
messages = state.Messages;
return true;
}
messages = null;
return false;
}
/// <summary>
/// Sets the in-memory chat message history for the specified agent session, replacing any existing messages.
/// </summary>
/// <remarks>
/// This method is only applicable when using <see cref="InMemoryChatHistoryProvider"/> and if the service does not require in-service chat history storage.
/// If messages are set, but a different <see cref="ChatHistoryProvider"/> is used, or if chat history is stored in the underlying AI service, the messages will be ignored.
/// </remarks>
/// <param name="session">The agent session whose in-memory chat history will be updated.</param>
/// <param name="messages">The list of chat messages to store in memory for the session. Replaces any existing messages for the specified
/// state key.</param>
/// <param name="stateKey">The key used to identify the in-memory chat history within the session's state bag. If null, a default key is
/// used.</param>
/// <param name="jsonSerializerOptions">The serializer options used when accessing or storing the state. If null, default options are applied.</param>
public static void SetInMemoryChatHistory(this AgentSession session, List<ChatMessage> messages, string? stateKey = null, JsonSerializerOptions? jsonSerializerOptions = null)
{
_ = Throw.IfNull(session);
if (session.StateBag.TryGetValue(stateKey ?? nameof(InMemoryChatHistoryProvider), out InMemoryChatHistoryProvider.State? state, jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions) && state is not null)
{
state.Messages = messages;
return;
}
session.StateBag.SetValue(stateKey ?? nameof(InMemoryChatHistoryProvider), new InMemoryChatHistoryProvider.State() { Messages = messages }, jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions);
}
}
@@ -0,0 +1,145 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides a thread-safe key-value store for managing session-scoped state with support for type-safe access and JSON
/// serialization options.
/// </summary>
/// <remarks>
/// SessionState enables storing and retrieving objects associated with a session using string keys.
/// Values can be accessed in a type-safe manner and are serialized or deserialized using configurable JSON serializer
/// options. This class is designed for concurrent access and is safe to use across multiple threads.
/// </remarks>
[JsonConverter(typeof(AgentSessionStateBagJsonConverter))]
public class AgentSessionStateBag
{
private readonly ConcurrentDictionary<string, AgentSessionStateBagValue> _state;
/// <summary>
/// Initializes a new instance of the <see cref="AgentSessionStateBag"/> class.
/// </summary>
public AgentSessionStateBag()
{
this._state = new ConcurrentDictionary<string, AgentSessionStateBagValue>();
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSessionStateBag"/> class.
/// </summary>
/// <param name="state">The initial state dictionary.</param>
internal AgentSessionStateBag(ConcurrentDictionary<string, AgentSessionStateBagValue>? state)
{
this._state = state ?? new ConcurrentDictionary<string, AgentSessionStateBagValue>();
}
/// <summary>
/// Gets the number of key-value pairs contained in the session state.
/// </summary>
public int Count => this._state.Count;
/// <summary>
/// Tries to get a value from the session state.
/// </summary>
/// <typeparam name="T">The type of the value to retrieve.</typeparam>
/// <param name="key">The key from which to retrieve the value.</param>
/// <param name="value">The value if found and convertible to the required type; otherwise, null.</param>
/// <param name="jsonSerializerOptions">The JSON serializer options to use for serializing/deserializing the value.</param>
/// <returns><see langword="true"/> if the value was successfully retrieved, <see langword="false"/> otherwise.</returns>
public bool TryGetValue<T>(string key, out T? value, JsonSerializerOptions? jsonSerializerOptions = null)
where T : class
{
_ = Throw.IfNullOrWhitespace(key);
var jso = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
if (this._state.TryGetValue(key, out var stateValue))
{
return stateValue.TryReadDeserializedValue(out value, jso);
}
value = null;
return false;
}
/// <summary>
/// Gets a value from the session state.
/// </summary>
/// <typeparam name="T">The type of value to get.</typeparam>
/// <param name="key">The key from which to retrieve the value.</param>
/// <param name="jsonSerializerOptions">The JSON serializer options to use for serializing/deserialing the value.</param>
/// <returns>The retrieved value or null if not found.</returns>
/// <exception cref="InvalidOperationException">The value could not be deserialized into the required type.</exception>
public T? GetValue<T>(string key, JsonSerializerOptions? jsonSerializerOptions = null)
where T : class
{
_ = Throw.IfNullOrWhitespace(key);
var jso = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
if (this._state.TryGetValue(key, out var stateValue))
{
return stateValue.ReadDeserializedValue<T>(jso);
}
return null;
}
/// <summary>
/// Sets a value in the session state.
/// </summary>
/// <typeparam name="T">The type of the value to set.</typeparam>
/// <param name="key">The key to store the value under.</param>
/// <param name="value">The value to set.</param>
/// <param name="jsonSerializerOptions">The JSON serializer options to use for serializing the value.</param>
public void SetValue<T>(string key, T? value, JsonSerializerOptions? jsonSerializerOptions = null)
where T : class
{
_ = Throw.IfNullOrWhitespace(key);
var jso = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
var stateValue = this._state.GetOrAdd(key, _ =>
new AgentSessionStateBagValue(value, typeof(T), jso));
stateValue.SetDeserialized(value, typeof(T), jso);
}
/// <summary>
/// Tries to remove a value from the session state.
/// </summary>
/// <param name="key">The key of the value to remove.</param>
/// <returns><see langword="true"/> if the value was successfully removed; otherwise, <see langword="false"/>.</returns>
public bool TryRemoveValue(string key)
=> this._state.TryRemove(Throw.IfNullOrWhitespace(key), out _);
/// <summary>
/// Serializes all session state values to a JSON object.
/// </summary>
/// <returns>A <see cref="JsonElement"/> representing the serialized session state.</returns>
/// <exception cref="InvalidOperationException">Thrown when a session state value is not properly initialized.</exception>
public JsonElement Serialize()
{
return JsonSerializer.SerializeToElement(this._state, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ConcurrentDictionary<string, AgentSessionStateBagValue>)));
}
/// <summary>
/// Deserializes a JSON object into an <see cref="AgentSessionStateBag"/> instance.
/// </summary>
/// <param name="jsonElement">The element to deserialize.</param>
/// <returns>The deserialized <see cref="AgentSessionStateBag"/>.</returns>
public static AgentSessionStateBag Deserialize(JsonElement jsonElement)
{
if (jsonElement.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
{
return new AgentSessionStateBag();
}
return new AgentSessionStateBag(
jsonElement.Deserialize(AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ConcurrentDictionary<string, AgentSessionStateBagValue>))) as ConcurrentDictionary<string, AgentSessionStateBagValue>
?? new ConcurrentDictionary<string, AgentSessionStateBagValue>());
}
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI;
/// <summary>
/// Custom JSON converter for <see cref="AgentSessionStateBag"/> that serializes and deserializes
/// the internal dictionary contents rather than the container object's public properties.
/// </summary>
public sealed class AgentSessionStateBagJsonConverter : JsonConverter<AgentSessionStateBag>
{
/// <inheritdoc/>
public override AgentSessionStateBag Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var element = JsonElement.ParseValue(ref reader);
return AgentSessionStateBag.Deserialize(element);
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, AgentSessionStateBag value, JsonSerializerOptions options)
{
var element = value.Serialize();
element.WriteTo(writer);
}
}
@@ -0,0 +1,182 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI;
/// <summary>
/// Used to store a value in session state.
/// </summary>
[JsonConverter(typeof(AgentSessionStateBagValueJsonConverter))]
internal class AgentSessionStateBagValue
{
private readonly object _lock = new();
private DeserializedCache? _cache;
private JsonElement _jsonValue;
/// <summary>
/// Initializes a new instance of the SessionStateValue class with the specified value.
/// </summary>
/// <param name="jsonValue">The serialized value to associate with the session state.</param>
public AgentSessionStateBagValue(JsonElement jsonValue)
{
this.JsonValue = jsonValue;
}
/// <summary>
/// Initializes a new instance of the SessionStateValue class with the specified value.
/// </summary>
/// <param name="deserializedValue">The value to associate with the session state. Can be any object, including null.</param>
/// <param name="valueType">The type of the value.</param>
/// <param name="jsonSerializerOptions">The JSON serializer options to use for serializing the value.</param>
public AgentSessionStateBagValue(object? deserializedValue, Type valueType, JsonSerializerOptions jsonSerializerOptions)
{
this._cache = new DeserializedCache(deserializedValue, valueType, jsonSerializerOptions);
}
/// <summary>
/// Gets or sets the value associated with this instance.
/// </summary>
public JsonElement JsonValue
{
get
{
lock (this._lock)
{
// We are assuming here that JsonValue will only be read when the object is being serialized,
// which means that we will only call SerializeToElement when serializing and therefore it's
// OK to serialize on each read if the cache is set.
if (this._cache is { } cache)
{
this._jsonValue = JsonSerializer.SerializeToElement(cache.Value, cache.Options.GetTypeInfo(cache.ValueType));
}
return this._jsonValue;
}
}
set
{
lock (this._lock)
{
this._jsonValue = value;
this._cache = null;
}
}
}
/// <summary>
/// Tries to read the deserialized value of this session state value.
/// Returns false if the value could not be deserialized into the required type, or if the value is undefined.
/// Returns true and sets the out parameter to null if the value is null.
/// </summary>
public bool TryReadDeserializedValue<T>(out T? value, JsonSerializerOptions? jsonSerializerOptions = null)
where T : class
{
var jso = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
lock (this._lock)
{
switch (this._cache)
{
case DeserializedCache { Value: null, ValueType: Type cacheValueType } when cacheValueType == typeof(T):
value = null;
return true;
case DeserializedCache { Value: T cacheValue, ValueType: Type cacheValueType } when cacheValueType == typeof(T):
value = cacheValue;
return true;
case DeserializedCache { ValueType: Type cacheValueType } when cacheValueType != typeof(T):
value = null;
return false;
}
switch (this._jsonValue)
{
case JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Undefined:
value = null;
return false;
case JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Null:
value = null;
return true;
default:
T? result = this._jsonValue.Deserialize(jso.GetTypeInfo(typeof(T))) as T;
if (result is null)
{
value = null;
return false;
}
this._cache = new DeserializedCache(result, typeof(T), jso);
value = result;
return true;
}
}
}
/// <summary>
/// Reads the deserialized value of this session state value, throwing an exception if the value could not be deserialized into the required type or is undefined.
/// </summary>
public T? ReadDeserializedValue<T>(JsonSerializerOptions? jsonSerializerOptions = null)
where T : class
{
var jso = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
lock (this._lock)
{
switch (this._cache)
{
case DeserializedCache { Value: null, ValueType: Type cacheValueType } when cacheValueType == typeof(T):
return null;
case DeserializedCache { Value: T cacheValue, ValueType: Type cacheValueType } when cacheValueType == typeof(T):
return cacheValue;
case DeserializedCache { ValueType: Type cacheValueType } when cacheValueType != typeof(T):
throw new InvalidOperationException($"The type of the cached value is {cacheValueType.FullName}, but the requested type is {typeof(T).FullName}.");
}
switch (this._jsonValue)
{
case JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Null || jsonElement.ValueKind == JsonValueKind.Undefined:
return null;
default:
T? result = this._jsonValue.Deserialize(jso.GetTypeInfo(typeof(T))) as T;
if (result is null)
{
throw new InvalidOperationException($"Failed to deserialize session state value to type {typeof(T).FullName}.");
}
this._cache = new DeserializedCache(result, typeof(T), jso);
return result;
}
}
}
/// <summary>
/// Sets the deserialized value of this session state value, updating the cache accordingly.
/// This does not update the JsonValue directly; the JsonValue will be updated on the next read or when the object is serialized.
/// </summary>
public void SetDeserialized<T>(T? deserializedValue, Type valueType, JsonSerializerOptions jsonSerializerOptions)
{
lock (this._lock)
{
this._cache = new DeserializedCache(deserializedValue, valueType, jsonSerializerOptions);
}
}
private readonly struct DeserializedCache
{
public DeserializedCache(object? value, Type valueType, JsonSerializerOptions options)
{
this.Value = value;
this.ValueType = valueType;
this.Options = options;
}
public object? Value { get; }
public Type ValueType { get; }
public JsonSerializerOptions Options { get; }
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI;
/// <summary>
/// Custom JSON converter for <see cref="AgentSessionStateBagValue"/> that serializes and deserializes
/// the <see cref="AgentSessionStateBagValue.JsonValue"/> directly rather than wrapping it in a container object.
/// </summary>
internal sealed class AgentSessionStateBagValueJsonConverter : JsonConverter<AgentSessionStateBagValue>
{
/// <inheritdoc/>
public override AgentSessionStateBagValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var element = JsonElement.ParseValue(ref reader);
return new AgentSessionStateBagValue(element);
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, AgentSessionStateBagValue value, JsonSerializerOptions options)
{
value.JsonValue.WriteTo(writer);
}
}
@@ -0,0 +1,478 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides an abstract base class for fetching chat messages from, and adding chat messages to, chat history for the purposes of agent execution.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="ChatHistoryProvider"/> defines the contract that an <see cref="AIAgent"/> can use to retrieve messsages from chat history
/// and provide notification of newly produced messages.
/// Implementations are responsible for managing message persistence, retrieval, and any necessary optimization
/// strategies such as truncation, summarization, or archival.
/// </para>
/// <para>
/// Key responsibilities include:
/// <list type="bullet">
/// <item><description>Storing chat messages with proper ordering and metadata preservation</description></item>
/// <item><description>Retrieving messages in chronological order for agent context</description></item>
/// <item><description>Managing storage limits through truncation, summarization, or other strategies</description></item>
/// </list>
/// </para>
/// <para>
/// The <see cref="ChatHistoryProvider"/> is passed a reference to the <see cref="AgentSession"/> via <see cref="InvokingContext"/> and <see cref="InvokedContext"/>
/// allowing it to store state in the <see cref="AgentSession.StateBag"/>. Since a <see cref="ChatHistoryProvider"/> is used with many different sessions, it should
/// not store any session-specific information within its own instance fields. Instead, any session-specific state should be stored in the associated <see cref="AgentSession.StateBag"/>.
/// </para>
/// <para>
/// A <see cref="ChatHistoryProvider"/> is only relevant for scenarios where the underlying AI service that the agent is using
/// does not use in-service chat history storage.
/// </para>
/// <para>
/// <strong>Security considerations:</strong> Agent Framework does not validate or filter the messages returned by the provider
/// during load — they are accepted as-is and treated identically to user-supplied messages. Implementers must ensure that only
/// trusted data is returned. If the underlying storage is compromised, adversarial content could influence LLM behavior via
/// indirect prompt injection — for example, injected messages could alter the conversation context or impersonate different roles.
/// Messages stored in chat history may contain PII and sensitive conversation content; implementers should consider encryption
/// at rest and appropriate access controls for the storage backend.
/// </para>
/// </remarks>
public abstract class ChatHistoryProvider
{
private static IEnumerable<ChatMessage> DefaultExcludeChatHistoryFilter(IEnumerable<ChatMessage> messages)
=> messages.Where(m => m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory);
private static IEnumerable<ChatMessage> DefaultNoopFilter(IEnumerable<ChatMessage> messages)
=> messages;
private IReadOnlyList<string>? _stateKeys;
private readonly Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? _provideOutputMessageFilter;
private readonly Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> _storeInputRequestMessageFilter;
private readonly Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> _storeInputResponseMessageFilter;
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryProvider"/> class.
/// </summary>
/// <param name="provideOutputMessageFilter">An optional filter function to apply to messages when retrieving them from the chat history.</param>
/// <param name="storeInputRequestMessageFilter">An optional filter function to apply to request messages before storing them in the chat history. If not set, defaults to excluding messages with source type <see cref="AgentRequestMessageSourceType.ChatHistory"/>.</param>
/// <param name="storeInputResponseMessageFilter">An optional filter function to apply to response messages before storing them in the chat history. If not set, defaults to a no-op filter that includes all response messages.</param>
protected ChatHistoryProvider(
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideOutputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputRequestMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputResponseMessageFilter = null)
{
this._provideOutputMessageFilter = provideOutputMessageFilter;
this._storeInputRequestMessageFilter = storeInputRequestMessageFilter ?? DefaultExcludeChatHistoryFilter;
this._storeInputResponseMessageFilter = storeInputResponseMessageFilter ?? DefaultNoopFilter;
}
/// <summary>
/// Gets the set of keys used to store the provider state in the <see cref="AgentSession.StateBag"/>.
/// </summary>
/// <remarks>
/// The default value is a single-element set containing the name of the concrete type (e.g. <c>"InMemoryChatHistoryProvider"</c>).
/// Implementations may override this to provide custom keys, for example when multiple
/// instances of the same provider type are used in the same session, or when a provider
/// stores state under more than one key.
/// </remarks>
public virtual IReadOnlyList<string> StateKeys => this._stateKeys ??= [this.GetType().Name];
/// <summary>
/// Called at the start of agent invocation to provide messages for the next agent invocation.
/// </summary>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains a collection of <see cref="ChatMessage"/>
/// instances that will be used for the agent invocation.
/// </returns>
/// <remarks>
/// <para>
/// If the total message history becomes very large, implementations should apply appropriate strategies to manage
/// storage constraints, such as:
/// <list type="bullet">
/// <item><description>Truncating older messages while preserving recent context</description></item>
/// <item><description>Summarizing message groups to maintain essential context</description></item>
/// <item><description>Implementing sliding window approaches for message retention</description></item>
/// <item><description>Archiving old messages while keeping active conversation context</description></item>
/// </list>
/// </para>
/// </remarks>
public ValueTask<IEnumerable<ChatMessage>> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
=> this.InvokingCoreAsync(Throw.IfNull(context), cancellationToken);
/// <summary>
/// Called at the start of agent invocation to provide messages for the next agent invocation.
/// </summary>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains a collection of <see cref="ChatMessage"/>
/// instances that will be used for the agent invocation.
/// </returns>
/// <remarks>
/// <para>
/// If the total message history becomes very large, implementations should apply appropriate strategies to manage
/// storage constraints, such as:
/// <list type="bullet">
/// <item><description>Truncating older messages while preserving recent context</description></item>
/// <item><description>Summarizing message groups to maintain essential context</description></item>
/// <item><description>Implementing sliding window approaches for message retention</description></item>
/// <item><description>Archiving old messages while keeping active conversation context</description></item>
/// </list>
/// </para>
/// <para>
/// The default implementation of this method, calls <see cref="ProvideChatHistoryAsync"/> to get the chat history messages, applies the optional retrieval output filter,
/// and merges the returned messages with the caller provided messages (with chat history messages appearing first) before returning the full message list to be used for the invocation.
/// For most scenarios, overriding <see cref="ProvideChatHistoryAsync"/> is sufficient to return the desired chat history messages, while still benefiting from the default merging and filtering behavior.
/// However, for scenarios that require more control over message filtering, merging or source stamping, overriding this method allows you to directly control the full set of messages returned for the invocation.
/// </para>
/// </remarks>
protected virtual async ValueTask<IEnumerable<ChatMessage>> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
var output = await this.ProvideChatHistoryAsync(context, cancellationToken).ConfigureAwait(false);
if (this._provideOutputMessageFilter is not null)
{
output = this._provideOutputMessageFilter(output);
}
return output
.Select(message => message.WithAgentRequestMessageSource(AgentRequestMessageSourceType.ChatHistory, this.GetType().FullName!))
.Concat(context.RequestMessages);
}
/// <summary>
/// When overridden in a derived class, provides the chat history messages to be used for the current invocation.
/// </summary>
/// <remarks>
/// <para>
/// This method is called from <see cref="InvokingCoreAsync"/>.
/// Note that <see cref="InvokingCoreAsync"/> can be overridden to directly control message filtering, merging and source stamping, in which case
/// it is up to the implementer to call this method as needed to retrieve the unfiltered/unmerged chat history messages.
/// </para>
/// <para>
/// In contrast with <see cref="InvokingCoreAsync"/>, this method only returns additional messages to be added to the request,
/// while <see cref="InvokingCoreAsync"/> is responsible for returning the full set of messages to be used for the invocation (including caller provided messages).
/// </para>
/// <para>
/// Messages are returned in chronological order to maintain proper conversation flow and context for the agent.
/// The oldest messages appear first in the collection, followed by more recent messages.
/// </para>
/// <para>
/// <strong>Security consideration:</strong> Messages loaded from storage should be treated with the same caution as user-supplied
/// messages. A compromised storage backend could alter message roles to escalate trust (e.g., changing <c>user</c> messages to
/// <c>system</c> messages) or inject adversarial content that influences LLM behavior.
/// </para>
/// </remarks>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains a collection of <see cref="ChatMessage"/>
/// instances in ascending chronological order (oldest first).
/// </returns>
protected virtual ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
return new ValueTask<IEnumerable<ChatMessage>>([]);
}
/// <summary>
/// Called at the end of the agent invocation to add new messages to the chat history.
/// </summary>
/// <param name="context">Contains the invocation context including request messages, response messages, and any exception that occurred.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous add operation.</returns>
/// <remarks>
/// <para>
/// Messages should be added in the order they were generated to maintain proper chronological sequence.
/// The <see cref="ChatHistoryProvider"/> is responsible for preserving message ordering and ensuring that subsequent calls to
/// <see cref="InvokingCoreAsync"/> return messages in the correct chronological order.
/// </para>
/// <para>
/// Implementations may perform additional processing during message addition, such as:
/// <list type="bullet">
/// <item><description>Validating message content and metadata</description></item>
/// <item><description>Applying storage optimizations or compression</description></item>
/// <item><description>Triggering background maintenance operations</description></item>
/// </list>
/// </para>
/// <para>
/// This method is called regardless of whether the invocation succeeded or failed.
/// To check if the invocation was successful, inspect the <see cref="InvokedContext.InvokeException"/> property.
/// </para>
/// </remarks>
public ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default) =>
this.InvokedCoreAsync(Throw.IfNull(context), cancellationToken);
/// <summary>
/// Called at the end of the agent invocation to add new messages to the chat history.
/// </summary>
/// <param name="context">Contains the invocation context including request messages, response messages, and any exception that occurred.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous add operation.</returns>
/// <remarks>
/// <para>
/// Messages should be added in the order they were generated to maintain proper chronological sequence.
/// The <see cref="ChatHistoryProvider"/> is responsible for preserving message ordering and ensuring that subsequent calls to
/// <see cref="InvokingCoreAsync"/> return messages in the correct chronological order.
/// </para>
/// <para>
/// Implementations may perform additional processing during message addition, such as:
/// <list type="bullet">
/// <item><description>Validating message content and metadata</description></item>
/// <item><description>Applying storage optimizations or compression</description></item>
/// <item><description>Triggering background maintenance operations</description></item>
/// </list>
/// </para>
/// <para>
/// This method is called regardless of whether the invocation succeeded or failed.
/// To check if the invocation was successful, inspect the <see cref="InvokedContext.InvokeException"/> property.
/// </para>
/// <para>
/// The default implementation of this method, skips execution for any invocation failures, filters messages using the optional storage input request and response message filters
/// and calls <see cref="StoreChatHistoryAsync"/> to store new chat history messages.
/// For most scenarios, overriding <see cref="StoreChatHistoryAsync"/> is sufficient to store chat history messages, while still benefiting from the default error handling and filtering behavior.
/// However, for scenarios that require more control over error handling or message filtering, overriding this method allows you to directly control the messages that are stored for the invocation.
/// </para>
/// </remarks>
protected virtual ValueTask InvokedCoreAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
if (context.InvokeException is not null)
{
return default;
}
#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
var subContext = new InvokedContext(context.Agent, context.Session, this._storeInputRequestMessageFilter(context.RequestMessages), this._storeInputResponseMessageFilter(context.ResponseMessages!));
#pragma warning restore MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
return this.StoreChatHistoryAsync(subContext, cancellationToken);
}
/// <summary>
/// When overridden in a derived class, adds new messages to the chat history at the end of the agent invocation.
/// </summary>
/// <param name="context">Contains the invocation context including request messages, response messages, and any exception that occurred.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous add operation.</returns>
/// <remarks>
/// <para>
/// Messages should be added in the order they were generated to maintain proper chronological sequence.
/// The <see cref="ChatHistoryProvider"/> is responsible for preserving message ordering and ensuring that subsequent calls to
/// <see cref="InvokingCoreAsync"/> return messages in the correct chronological order.
/// </para>
/// <para>
/// Implementations may perform additional processing during message addition, such as:
/// <list type="bullet">
/// <item><description>Validating message content and metadata</description></item>
/// <item><description>Applying storage optimizations or compression</description></item>
/// <item><description>Triggering background maintenance operations</description></item>
/// </list>
/// </para>
/// <para>
/// This method is called from <see cref="InvokedCoreAsync"/>.
/// Note that <see cref="InvokedCoreAsync"/> can be overridden to directly control message filtering and error handling, in which case
/// it is up to the implementer to call this method as needed to store messages.
/// </para>
/// <para>
/// In contrast with <see cref="InvokedCoreAsync"/>, this method only stores messages,
/// while <see cref="InvokedCoreAsync"/> is also responsible for messages filtering and error handling.
/// </para>
/// <para>
/// The default implementation of <see cref="InvokedCoreAsync"/> only calls this method if the invocation succeeded.
/// </para>
/// <para>
/// <strong>Security consideration:</strong> Messages being stored may contain PII and sensitive conversation content.
/// Implementers should ensure appropriate encryption at rest and access controls for the storage backend.
/// </para>
/// </remarks>
protected virtual ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) =>
default;
/// <summary>Asks the <see cref="ChatHistoryProvider"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
/// <param name="serviceType">The type of object being requested.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="serviceType"/> is <see langword="null"/>.</exception>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the <see cref="ChatHistoryProvider"/>,
/// including itself or any services it might be wrapping.
/// </remarks>
public virtual object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);
return serviceKey is null && serviceType.IsInstanceOfType(this)
? this
: null;
}
/// <summary>Asks the <see cref="ChatHistoryProvider"/> for an object of type <typeparamref name="TService"/>.</summary>
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the <see cref="ChatHistoryProvider"/>,
/// including itself or any services it might be wrapping.
/// </remarks>
public TService? GetService<TService>(object? serviceKey = null)
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
/// <summary>
/// Contains the context information provided to <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// This class provides context about the invocation including the new messages that will be used.
/// A <see cref="ChatHistoryProvider"/> can use this information to determine what messages should be provided
/// for the invocation.
/// </remarks>
public sealed class InvokingContext
{
/// <summary>
/// Initializes a new instance of the <see cref="InvokingContext"/> class with the specified request messages.
/// </summary>
/// <param name="agent">The agent being invoked.</param>
/// <param name="session">The session associated with the agent invocation.</param>
/// <param name="requestMessages">The messages to be used by the agent for this invocation.</param>
/// <exception cref="ArgumentNullException"><paramref name="requestMessages"/> is <see langword="null"/>.</exception>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public InvokingContext(
AIAgent agent,
AgentSession? session,
IEnumerable<ChatMessage> requestMessages)
{
this.Agent = Throw.IfNull(agent);
this.Session = session;
this.RequestMessages = Throw.IfNull(requestMessages);
}
/// <summary>
/// Gets the agent that is being invoked.
/// </summary>
public AIAgent Agent { get; }
/// <summary>
/// Gets the agent session associated with the agent invocation.
/// </summary>
public AgentSession? Session { get; }
/// <summary>
/// Gets the messages that will be used by the agent for this invocation. <see cref="ChatHistoryProvider"/> instances can modify
/// and return or return a new message list to add additional messages for the invocation.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing the messages that will be used by the agent for this invocation.
/// </value>
/// <remarks>
/// <para>
/// If multiple <see cref="ChatHistoryProvider"/> instances are used in the same invocation, each <see cref="ChatHistoryProvider"/>
/// will receive the messages returned by the previous <see cref="ChatHistoryProvider"/> allowing them to build on top of each other's context.
/// </para>
/// <para>
/// The first <see cref="ChatHistoryProvider"/> in the invocation pipeline will receive the
/// caller provided messages.
/// </para>
/// </remarks>
public IEnumerable<ChatMessage> RequestMessages { get; set { field = Throw.IfNull(value); } }
}
/// <summary>
/// Contains the context information provided to <see cref="InvokedCoreAsync(InvokedContext, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// This class provides context about a completed agent invocation, including the accumulated
/// request messages (user input, chat history and any others provided by AI context providers) that were used
/// and the response messages that were generated. It also indicates whether the invocation succeeded or failed.
/// </remarks>
public sealed class InvokedContext
{
/// <summary>
/// Initializes a new instance of the <see cref="InvokedContext"/> class for a successful invocation.
/// </summary>
/// <param name="agent">The agent that was invoked.</param>
/// <param name="session">The session associated with the agent invocation.</param>
/// <param name="requestMessages">The accumulated request messages (user input, chat history and any others provided by AI context providers)
/// that were used by the agent for this invocation.</param>
/// <param name="responseMessages">The response messages generated during this invocation.</param>
/// <exception cref="ArgumentNullException"><paramref name="agent"/>, <paramref name="requestMessages"/>, or <paramref name="responseMessages"/> is <see langword="null"/>.</exception>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public InvokedContext(
AIAgent agent,
AgentSession? session,
IEnumerable<ChatMessage> requestMessages,
IEnumerable<ChatMessage> responseMessages)
{
this.Agent = Throw.IfNull(agent);
this.Session = session;
this.RequestMessages = Throw.IfNull(requestMessages);
this.ResponseMessages = Throw.IfNull(responseMessages);
}
/// <summary>
/// Initializes a new instance of the <see cref="InvokedContext"/> class for a failed invocation.
/// </summary>
/// <param name="agent">The agent that was invoked.</param>
/// <param name="session">The session associated with the agent invocation.</param>
/// <param name="requestMessages">The accumulated request messages (user input, chat history and any others provided by AI context providers)
/// that were used by the agent for this invocation.</param>
/// <param name="invokeException">The exception that caused the invocation to fail.</param>
/// <exception cref="ArgumentNullException"><paramref name="agent"/>, <paramref name="requestMessages"/>, or <paramref name="invokeException"/> is <see langword="null"/>.</exception>
public InvokedContext(
AIAgent agent,
AgentSession? session,
IEnumerable<ChatMessage> requestMessages,
Exception invokeException)
{
this.Agent = Throw.IfNull(agent);
this.Session = session;
this.RequestMessages = Throw.IfNull(requestMessages);
this.InvokeException = Throw.IfNull(invokeException);
}
/// <summary>
/// Gets the agent that is being invoked.
/// </summary>
public AIAgent Agent { get; }
/// <summary>
/// Gets the agent session associated with the agent invocation.
/// </summary>
public AgentSession? Session { get; }
/// <summary>
/// Gets the accumulated request messages (user input, chat history and any others provided by AI context providers)
/// that were used by the agent for this invocation.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing new messages that were provided by the caller.
/// This does not include any <see cref="ChatHistoryProvider"/> supplied messages.
/// </value>
public IEnumerable<ChatMessage> RequestMessages { get; }
/// <summary>
/// Gets the collection of response messages generated during this invocation if the invocation succeeded.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing the response,
/// or <see langword="null"/> if the invocation failed.
/// </value>
public IEnumerable<ChatMessage>? ResponseMessages { get; }
/// <summary>
/// Gets the <see cref="Exception"/> that was thrown during the invocation, if the invocation failed.
/// </summary>
/// <value>
/// The exception that caused the invocation to fail, or <see langword="null"/> if the invocation succeeded.
/// </value>
public Exception? InvokeException { get; }
}
}
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Contains extension methods for <see cref="ChatMessage"/>
/// </summary>
public static class ChatMessageExtensions
{
/// <summary>
/// Gets the source type of the provided <see cref="ChatMessage"/> in the context of messages passed into an agent run.
/// </summary>
/// <param name="message">The <see cref="ChatMessage"/> for which we need the source type.</param>
/// <returns>An <see cref="AgentRequestMessageSourceType"/> value indicating the source type of the <see cref="ChatMessage"/>. Defaults to <see
/// cref="AgentRequestMessageSourceType.External"/> if no explicit source is defined.</returns>
public static AgentRequestMessageSourceType GetAgentRequestMessageSourceType(this ChatMessage message)
{
if (message.AdditionalProperties?.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out var attribution) is true
&& attribution is AgentRequestMessageSourceAttribution typedAttribution)
{
return typedAttribution.SourceType;
}
return AgentRequestMessageSourceType.External;
}
/// <summary>
/// Gets the source id of the provided <see cref="ChatMessage"/> in the context of messages passed into an agent run.
/// </summary>
/// <param name="message">The <see cref="ChatMessage"/> for which we need the source id.</param>
/// <returns>An <see cref="string"/> value indicating the source id of the <see cref="ChatMessage"/>. Defaults to <see langword="null"/>
/// if no explicit source id is defined.</returns>
public static string? GetAgentRequestMessageSourceId(this ChatMessage message)
{
if (message.AdditionalProperties?.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out var attribution) is true
&& attribution is AgentRequestMessageSourceAttribution typedAttribution)
{
return typedAttribution.SourceId;
}
return null;
}
/// <summary>
/// Ensure that the provided message is tagged with the provided source type and source id in the context of a specific agent run.
/// </summary>
/// <param name="message">The message to tag.</param>
/// <param name="sourceType">The source type to tag the message with.</param>
/// <param name="sourceId">The source id to tag the message with.</param>
/// <returns>The tagged message.</returns>
/// <remarks>
/// If the message is already tagged with the provided source type and source id, it is returned as is.
/// Otherwise, a cloned message is returned with the appropriate tagging in the AdditionalProperties.
/// </remarks>
public static ChatMessage WithAgentRequestMessageSource(this ChatMessage message, AgentRequestMessageSourceType sourceType, string? sourceId = null)
{
if (message.AdditionalProperties != null
// Check if the message was already tagged with the required source type and source id
&& message.AdditionalProperties.TryGetValue(AgentRequestMessageSourceAttribution.AdditionalPropertiesKey, out var messageSourceAttribution)
&& messageSourceAttribution is AgentRequestMessageSourceAttribution typedMessageSourceAttribution
&& typedMessageSourceAttribution.SourceType == sourceType
&& typedMessageSourceAttribution.SourceId == sourceId)
{
return message;
}
message = message.Clone();
message.AdditionalProperties ??= new();
message.AdditionalProperties[AgentRequestMessageSourceAttribution.AdditionalPropertiesKey] =
new AgentRequestMessageSourceAttribution(sourceType, sourceId);
return message;
}
}
@@ -0,0 +1,102 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides an abstract base class for AI agents that delegate operations to an inner agent
/// instance while allowing for extensibility and customization.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="DelegatingAIAgent"/> implements the decorator pattern for <see cref="AIAgent"/>s, enabling the creation of agent pipelines
/// where each layer can add functionality while delegating core operations to an underlying agent. This pattern is
/// fundamental to building composable agent architectures.
/// </para>
/// <para>
/// The default implementation provides transparent pass-through behavior, forwarding all operations to the inner agent.
/// Derived classes can override specific methods to add custom behavior while maintaining compatibility with the agent interface.
/// </para>
/// </remarks>
public abstract class DelegatingAIAgent : AIAgent
{
/// <summary>
/// Initializes a new instance of the <see cref="DelegatingAIAgent"/> class with the specified inner agent.
/// </summary>
/// <param name="innerAgent">The underlying agent instance that will handle the core operations.</param>
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> is <see langword="null"/>.</exception>
/// <remarks>
/// The inner agent serves as the foundation of the delegation chain. All operations not overridden by
/// derived classes will be forwarded to this agent.
/// </remarks>
protected DelegatingAIAgent(AIAgent innerAgent)
{
this.InnerAgent = Throw.IfNull(innerAgent);
}
/// <summary>
/// Gets the inner agent instance that receives delegated operations.
/// </summary>
/// <value>
/// The underlying <see cref="AIAgent"/> instance that handles core agent operations.
/// </value>
/// <remarks>
/// Derived classes can use this property to access the inner agent for custom delegation scenarios
/// or to forward operations with additional processing.
/// </remarks>
protected AIAgent InnerAgent { get; }
/// <inheritdoc />
protected override string? IdCore => this.InnerAgent.Id;
/// <inheritdoc />
public override string? Name => this.InnerAgent.Name;
/// <inheritdoc />
public override string? Description => this.InnerAgent.Description;
/// <inheritdoc />
public override object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);
// If the key is non-null, we don't know what it means so pass through to the inner service.
return
serviceKey is null && serviceType.IsInstanceOfType(this) ? this :
this.InnerAgent.GetService(serviceType, serviceKey);
}
/// <inheritdoc />
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) => this.InnerAgent.CreateSessionAsync(cancellationToken);
/// <inheritdoc />
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> this.InnerAgent.SerializeSessionAsync(session, jsonSerializerOptions, cancellationToken);
/// <inheritdoc />
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> this.InnerAgent.DeserializeSessionAsync(serializedState, jsonSerializerOptions, cancellationToken);
/// <inheritdoc />
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
=> this.InnerAgent.RunAsync(messages, session, options, cancellationToken);
/// <inheritdoc />
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
=> this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken);
}
@@ -0,0 +1,134 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides an in-memory implementation of <see cref="ChatHistoryProvider"/> with support for message reduction.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="InMemoryChatHistoryProvider"/> stores chat messages in the <see cref="AgentSession.StateBag"/>,
/// providing fast access and manipulation capabilities integrated with session state management.
/// </para>
/// <para>
/// This <see cref="ChatHistoryProvider"/> maintains all messages in memory. For long-running conversations or high-volume scenarios, consider using
/// message reduction strategies or alternative storage implementations.
/// </para>
/// </remarks>
public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
{
private readonly ProviderSessionState<State> _sessionState;
private IReadOnlyList<string>? _stateKeys;
/// <summary>
/// Initializes a new instance of the <see cref="InMemoryChatHistoryProvider"/> class.
/// </summary>
/// <param name="options">
/// Optional configuration options that control the provider's behavior, including state initialization,
/// message reduction, and serialization settings. If <see langword="null"/>, default settings will be used.
/// </param>
public InMemoryChatHistoryProvider(InMemoryChatHistoryProviderOptions? options = null)
: base(
options?.ProvideOutputMessageFilter,
options?.StorageInputRequestMessageFilter,
options?.StorageInputResponseMessageFilter)
{
this._sessionState = new ProviderSessionState<State>(
options?.StateInitializer ?? (_ => new State()),
options?.StateKey ?? this.GetType().Name,
options?.JsonSerializerOptions);
this.ChatReducer = options?.ChatReducer;
this.ReducerTriggerEvent = options?.ReducerTriggerEvent ?? InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval;
}
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
/// <summary>
/// Gets the chat reducer used to process or reduce chat messages. If null, no reduction logic will be applied.
/// </summary>
public IChatReducer? ChatReducer { get; }
/// <summary>
/// Gets the event that triggers the reducer invocation in this provider.
/// </summary>
public InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent ReducerTriggerEvent { get; }
/// <summary>
/// Gets the chat messages stored for the specified session.
/// </summary>
/// <param name="session">The agent session containing the state.</param>
/// <returns>A list of chat messages, or an empty list if no state is found.</returns>
public List<ChatMessage> GetMessages(AgentSession? session)
=> this._sessionState.GetOrInitializeState(session).Messages;
/// <summary>
/// Sets the chat messages for the specified session.
/// </summary>
/// <param name="session">The agent session containing the state.</param>
/// <param name="messages">The messages to store.</param>
/// <exception cref="ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
public void SetMessages(AgentSession? session, List<ChatMessage> messages)
{
Throw.IfNull(messages);
State state = this._sessionState.GetOrInitializeState(session);
state.Messages = messages;
}
/// <inheritdoc />
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
State state = this._sessionState.GetOrInitializeState(context.Session);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null)
{
// Apply pre-retrieval reduction if configured
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
}
return state.Messages;
}
/// <inheritdoc />
protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
State state = this._sessionState.GetOrInitializeState(context.Session);
// Add request and response messages to the provider
var allNewMessages = (context.RequestMessages ?? []).Concat(context.ResponseMessages ?? []);
state.Messages.AddRange(allNewMessages);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
{
// Apply pre-write reduction strategy if configured
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
}
}
private static async Task ReduceMessagesAsync(IChatReducer reducer, State state, CancellationToken cancellationToken = default)
{
state.Messages = [.. await reducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)];
}
/// <summary>
/// Represents the state of a <see cref="InMemoryChatHistoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
/// </summary>
public sealed class State
{
/// <summary>
/// Gets or sets the list of chat messages.
/// </summary>
[JsonPropertyName("messages")]
public List<ChatMessage> Messages { get; set; } = [];
}
}
@@ -0,0 +1,105 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents configuration options for <see cref="InMemoryChatHistoryProvider"/>.
/// </summary>
public sealed class InMemoryChatHistoryProviderOptions
{
/// <summary>
/// Gets or sets an optional delegate that initializes the provider state on the first invocation.
/// If <see langword="null"/>, a default initializer that creates an empty state will be used.
/// </summary>
public Func<AgentSession?, InMemoryChatHistoryProvider.State>? StateInitializer { get; set; }
/// <summary>
/// Gets or sets an optional <see cref="IChatReducer"/> instance used to process, reduce, or optimize chat messages.
/// This can be used to implement strategies like message summarization, truncation, or cleanup.
/// </summary>
public IChatReducer? ChatReducer { get; set; }
/// <summary>
/// Gets or sets when the message reducer should be invoked.
/// The default is <see cref="ChatReducerTriggerEvent.BeforeMessagesRetrieval"/>,
/// which applies reduction logic when messages are retrieved for agent consumption.
/// </summary>
/// <remarks>
/// Message reducers enable automatic management of message storage by implementing strategies to
/// keep memory usage under control while preserving important conversation context.
/// </remarks>
public ChatReducerTriggerEvent ReducerTriggerEvent { get; set; } = ChatReducerTriggerEvent.BeforeMessagesRetrieval;
/// <summary>
/// Gets or sets an optional key to use for storing the state in the <see cref="AgentSession.StateBag"/>.
/// If <see langword="null"/>, a default key will be used.
/// </summary>
public string? StateKey { get; set; }
/// <summary>
/// Gets or sets optional JSON serializer options for serializing the state of this provider.
/// This is valuable for cases like when the chat history contains custom <see cref="AIContent"/> types
/// and source generated serializers are required, or Native AOT / Trimming is required.
/// </summary>
public JsonSerializerOptions? JsonSerializerOptions { get; set; }
/// <summary>
/// Gets or sets an optional filter function applied to request messages before they are added to storage
/// during <see cref="ChatHistoryProvider.InvokedAsync"/>.
/// </summary>
/// <value>
/// When <see langword="null"/>, the provider defaults to excluding messages with
/// <see cref="AgentRequestMessageSourceType.ChatHistory"/> source type to avoid
/// storing messages that came from chat history in the first place.
/// Depending on your requirements, you could provide a different filter, that also excludes
/// messages from e.g. AI context providers.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputRequestMessageFilter { get; set; }
/// <summary>
/// Gets or sets an optional filter function applied to response messages before they are added to storage
/// during <see cref="ChatHistoryProvider.InvokedAsync"/>.
/// </summary>
/// <value>
/// When <see langword="null"/>, no filtering is applied to response messages before they are stored.
/// If you want to avoid persisting certain messages (for example, those with
/// <see cref="AgentRequestMessageSourceType.ChatHistory"/> source type or produced by AI context providers),
/// provide a filter that returns only the messages you want to keep.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputResponseMessageFilter { get; set; }
/// <summary>
/// Gets or sets an optional filter function applied to messages produced by this provider
/// during <see cref="ChatHistoryProvider.InvokingAsync"/>.
/// </summary>
/// <remarks>
/// This filter is only applied to the messages that the provider itself produces (from its internal storage).
/// </remarks>
/// <value>
/// When <see langword="null"/>, no filtering is applied to the output messages.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? ProvideOutputMessageFilter { get; set; }
/// <summary>
/// Defines the events that can trigger a reducer in the <see cref="InMemoryChatHistoryProvider"/>.
/// </summary>
public enum ChatReducerTriggerEvent
{
/// <summary>
/// Trigger the reducer when a new message is added.
/// <see cref="AIContextProvider.InvokedAsync"/> will only complete when reducer processing is done.
/// </summary>
AfterMessageAdded,
/// <summary>
/// Trigger the reducer before messages are retrieved from the provider.
/// The reducer will process the messages before they are returned to the caller.
/// </summary>
BeforeMessagesRetrieval
}
}
@@ -0,0 +1,212 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides an abstract base class for components that enhance AI context during agent invocations by supplying additional chat messages.
/// </summary>
/// <remarks>
/// <para>
/// A message AI context provider is a component that participates in the agent invocation lifecycle by:
/// <list type="bullet">
/// <item><description>Listening to changes in conversations</description></item>
/// <item><description>Providing additional messages to agents during invocation</description></item>
/// <item><description>Processing invocation results for state management or learning</description></item>
/// </list>
/// </para>
/// <para>
/// Context providers operate through a two-phase lifecycle: they are called at the start of invocation via
/// <see cref="AIContextProvider.InvokingAsync"/> to provide context, and optionally called at the end of invocation via
/// <see cref="AIContextProvider.InvokedAsync"/> to process results.
/// </para>
/// </remarks>
public abstract class MessageAIContextProvider : AIContextProvider
{
/// <summary>
/// Initializes a new instance of the <see cref="MessageAIContextProvider"/> class.
/// </summary>
/// <param name="provideInputMessageFilter">An optional filter function to apply to input messages before providing messages via <see cref="ProvideMessagesAsync"/>. If not set, defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages.</param>
/// <param name="storeInputRequestMessageFilter">An optional filter function to apply to request messages before storing messages via <see cref="AIContextProvider.StoreAIContextAsync"/>. If not set, defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages.</param>
/// <param name="storeInputResponseMessageFilter">An optional filter function to apply to response messages before storing messages via <see cref="AIContextProvider.StoreAIContextAsync"/>. If not set, defaults to including all response messages (no filtering).</param>
protected MessageAIContextProvider(
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideInputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputRequestMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputResponseMessageFilter = null)
: base(provideInputMessageFilter, storeInputRequestMessageFilter, storeInputResponseMessageFilter)
{
}
/// <inheritdoc/>
protected override async ValueTask<AIContext> ProvideAIContextAsync(AIContextProvider.InvokingContext context, CancellationToken cancellationToken = default)
{
// Call ProvideMessagesAsync directly to return only additional messages.
// The base AIContextProvider.InvokingCoreAsync handles merging with the original input and stamping.
#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
return new AIContext
{
Messages = await this.ProvideMessagesAsync(
new InvokingContext(context.Agent, context.Session, context.AIContext.Messages ?? []),
cancellationToken).ConfigureAwait(false)
};
#pragma warning restore MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
}
/// <summary>
/// Called at the start of agent invocation to provide additional messages.
/// </summary>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the <see cref="IEnumerable{ChatMessage}"/> to be used by the agent during this invocation.</returns>
/// <remarks>
/// <para>
/// Implementers can load any additional messages required at this time, such as:
/// <list type="bullet">
/// <item><description>Retrieving relevant information from knowledge bases</description></item>
/// <item><description>Adding system instructions or prompts</description></item>
/// <item><description>Injecting contextual messages from conversation history</description></item>
/// </list>
/// </para>
/// </remarks>
public ValueTask<IEnumerable<ChatMessage>> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
=> this.InvokingCoreAsync(Throw.IfNull(context), cancellationToken);
/// <summary>
/// Called at the start of agent invocation to provide additional messages.
/// </summary>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the <see cref="IEnumerable{ChatMessage}"/> to be used by the agent during this invocation.</returns>
/// <remarks>
/// <para>
/// Implementers can load any additional messages required at this time, such as:
/// <list type="bullet">
/// <item><description>Retrieving relevant information from knowledge bases</description></item>
/// <item><description>Adding system instructions or prompts</description></item>
/// <item><description>Injecting contextual messages from conversation history</description></item>
/// </list>
/// </para>
/// <para>
/// The default implementation of this method filters the input messages using the configured provide-input message filter
/// (which defaults to including only <see cref="AgentRequestMessageSourceType.External"/> messages),
/// then calls <see cref="ProvideMessagesAsync"/> to get additional messages,
/// stamps any messages with <see cref="AgentRequestMessageSourceType.AIContextProvider"/> source attribution,
/// and merges the returned messages with the original (unfiltered) input messages.
/// For most scenarios, overriding <see cref="ProvideMessagesAsync"/> is sufficient to provide additional messages,
/// while still benefiting from the default filtering, merging and source stamping behavior.
/// However, for scenarios that require more control over message filtering, merging or source stamping, overriding this method
/// allows you to directly control the full <see cref="IEnumerable{ChatMessage}"/> returned for the invocation.
/// </para>
/// </remarks>
protected virtual async ValueTask<IEnumerable<ChatMessage>> InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
var inputMessages = context.RequestMessages;
// Create a filtered context for ProvideMessagesAsync, filtering input messages
// to exclude non-external messages (e.g. chat history, other AI context provider messages).
#pragma warning disable MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
var filteredContext = new InvokingContext(
context.Agent,
context.Session,
this.ProvideInputMessageFilter(inputMessages));
#pragma warning restore MAAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
var providedMessages = await this.ProvideMessagesAsync(filteredContext, cancellationToken).ConfigureAwait(false);
// Stamp and merge provided messages.
providedMessages = providedMessages.Select(m => m.WithAgentRequestMessageSource(AgentRequestMessageSourceType.AIContextProvider, this.GetType().FullName!));
return inputMessages.Concat(providedMessages);
}
/// <summary>
/// When overridden in a derived class, provides additional messages to be merged with the input messages for the current invocation.
/// </summary>
/// <remarks>
/// <para>
/// This method is called from <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/>.
/// Note that <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/> can be overridden to directly control messages merging and source stamping, in which case
/// it is up to the implementer to call this method as needed to retrieve the additional messages.
/// </para>
/// <para>
/// In contrast with <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/>, this method only returns additional messages to be merged with the input,
/// while <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/> is responsible for returning the full merged <see cref="IEnumerable{ChatMessage}"/> for the invocation.
/// </para>
/// </remarks>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains an <see cref="IEnumerable{ChatMessage}"/>
/// with additional messages to be merged with the input messages.
/// </returns>
protected virtual ValueTask<IEnumerable<ChatMessage>> ProvideMessagesAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
return new ValueTask<IEnumerable<ChatMessage>>([]);
}
/// <summary>
/// Contains the context information provided to <see cref="InvokingCoreAsync(InvokingContext, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// This class provides context about the invocation before the underlying AI model is invoked, including the messages
/// that will be used. Message AI Context providers can use this information to determine what additional messages
/// should be provided for the invocation.
/// </remarks>
public new sealed class InvokingContext
{
/// <summary>
/// Initializes a new instance of the <see cref="InvokingContext"/> class with the specified request messages.
/// </summary>
/// <param name="agent">The agent being invoked.</param>
/// <param name="session">The session associated with the agent invocation.</param>
/// <param name="requestMessages">The messages to be used by the agent for this invocation.</param>
/// <exception cref="ArgumentNullException"><paramref name="agent"/> or <paramref name="requestMessages"/> is <see langword="null"/>.</exception>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public InvokingContext(
AIAgent agent,
AgentSession? session,
IEnumerable<ChatMessage> requestMessages)
{
this.Agent = Throw.IfNull(agent);
this.Session = session;
this.RequestMessages = Throw.IfNull(requestMessages);
}
/// <summary>
/// Gets the agent that is being invoked.
/// </summary>
public AIAgent Agent { get; }
/// <summary>
/// Gets the agent session associated with the agent invocation.
/// </summary>
public AgentSession? Session { get; }
/// <summary>
/// Gets the messages that will be used by the agent for this invocation. <see cref="MessageAIContextProvider"/> instances can modify
/// and return or return a new message list to add additional messages for the invocation.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing the messages that will be used by the agent for this invocation.
/// </value>
/// <remarks>
/// <para>
/// If multiple <see cref="MessageAIContextProvider"/> instances are used in the same invocation, each <see cref="MessageAIContextProvider"/>
/// will receive the messages returned by the previous <see cref="MessageAIContextProvider"/> allowing them to build on top of each other's context.
/// </para>
/// <para>
/// The first <see cref="MessageAIContextProvider"/> in the invocation pipeline will receive the
/// caller provided messages.
/// </para>
/// </remarks>
public IEnumerable<ChatMessage> RequestMessages { get; set { field = Throw.IfNull(value); } }
}
}
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Microsoft.Agents.AI</RootNamespace>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
<IsReleased>true</IsReleased>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedStructuredOutput>true</InjectSharedStructuredOutput>
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectRequiredMemberOnLegacy>true</InjectRequiredMemberOnLegacy>
<InjectCompilerFeatureRequiredOnLegacy>true</InjectCompilerFeatureRequiredOnLegacy>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Abstractions</Title>
<Description>Provides Microsoft Agent Framework interfaces and abstractions.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Abstractions.UnitTests" />
</ItemGroup>
</Project>
@@ -0,0 +1,87 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides strongly-typed state management for providers, enabling reading and writing of provider-specific state
/// to and from an <see cref="AgentSession"/>'s <see cref="AgentSessionStateBag"/>.
/// </summary>
/// <typeparam name="TState">The type of the state to be maintained. Must be a reference type.</typeparam>
/// <remarks>
/// <para>
/// This class encapsulates the logic for initializing, retrieving, and persisting provider state in the session's StateBag
/// using a configurable key and JSON serialization options. It is intended to be used as a composed field within provider
/// implementations (e.g., <see cref="AIContextProvider"/> or <see cref="ChatHistoryProvider"/> subclasses) to avoid
/// duplicating state management logic across provider type hierarchies.
/// </para>
/// <para>
/// State is stored in the <see cref="AgentSession.StateBag"/> using the <see cref="StateKey"/> property as the key,
/// enabling multiple providers to maintain independent state within the same session.
/// </para>
/// </remarks>
public class ProviderSessionState<TState>
where TState : class
{
private readonly Func<AgentSession?, TState> _stateInitializer;
private readonly JsonSerializerOptions _jsonSerializerOptions;
/// <summary>
/// Initializes a new instance of the <see cref="ProviderSessionState{TState}"/> class.
/// </summary>
/// <param name="stateInitializer">A function to initialize the state when it is not yet present in the session's StateBag.</param>
/// <param name="stateKey">The key used to store the state in the session's StateBag.</param>
/// <param name="jsonSerializerOptions">Options for JSON serialization and deserialization of the state.</param>
public ProviderSessionState(
Func<AgentSession?, TState> stateInitializer,
string stateKey,
JsonSerializerOptions? jsonSerializerOptions = null)
{
this._stateInitializer = Throw.IfNull(stateInitializer);
this.StateKey = Throw.IfNullOrWhitespace(stateKey);
this._jsonSerializerOptions = jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions;
}
/// <summary>
/// Gets the key used to store the provider state in the <see cref="AgentSession.StateBag"/>.
/// </summary>
public string StateKey { get; }
/// <summary>
/// Gets the state from the session's StateBag, or initializes it using the state initializer if not present.
/// </summary>
/// <param name="session">The agent session containing the StateBag.</param>
/// <returns>The provider state.</returns>
public TState GetOrInitializeState(AgentSession? session)
{
if (session?.StateBag.TryGetValue<TState>(this.StateKey, out var state, this._jsonSerializerOptions) is true && state is not null)
{
return state;
}
state = this._stateInitializer(session);
if (session is not null)
{
session.StateBag.SetValue(this.StateKey, state, this._jsonSerializerOptions);
}
return state;
}
/// <summary>
/// Saves the specified state to the session's StateBag using the configured state key and JSON serializer options.
/// If the session is null, this method does nothing.
/// </summary>
/// <param name="session">The agent session containing the StateBag.</param>
/// <param name="state">The state to be saved.</param>
public void SaveState(AgentSession? session, TState state)
{
if (session is not null)
{
session.StateBag.SetValue(this.StateKey, state, this._jsonSerializerOptions);
}
}
}
@@ -0,0 +1,103 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Anthropic.Services;
/// <summary>
/// Provides extension methods for the <see cref="IBetaService"/> class.
/// </summary>
public static class AnthropicBetaServiceExtensions
{
/// <summary>
/// Specifies the default maximum number of tokens allowed for processing operations.
/// </summary>
public static int DefaultMaxTokens { get; set; } = 4096;
/// <summary>
/// Creates a new AI agent using the specified model and options.
/// </summary>
/// <param name="betaService">The Anthropic beta service.</param>
/// <param name="model">The model to use for chat completions.</param>
/// <param name="instructions">The instructions for the AI agent.</param>
/// <param name="name">The name of the AI agent.</param>
/// <param name="description">The description of the AI agent.</param>
/// <param name="tools">The tools available to the AI agent.</param>
/// <param name="defaultMaxTokens">The default maximum tokens for chat completions. Defaults to <see cref="DefaultMaxTokens"/> if not provided.</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 enabling logging within 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>
/// <returns>The created <see cref="ChatClientAgent"/> AI agent.</returns>
public static ChatClientAgent AsAIAgent(
this IBetaService betaService,
string model,
string? instructions = null,
string? name = null,
string? description = null,
IList<AITool>? tools = null,
int? defaultMaxTokens = null,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null,
IServiceProvider? services = null)
{
var options = new ChatClientAgentOptions
{
Name = name,
Description = description,
};
if (!string.IsNullOrWhiteSpace(instructions))
{
options.ChatOptions ??= new();
options.ChatOptions.Instructions = instructions;
}
if (tools is { Count: > 0 })
{
options.ChatOptions ??= new();
options.ChatOptions.Tools = tools;
}
var chatClient = betaService.AsIChatClient(model, defaultMaxTokens ?? DefaultMaxTokens);
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
}
return new ChatClientAgent(chatClient, options, loggerFactory, services);
}
/// <summary>
/// Creates an AI agent from an <see cref="IBetaService"/> using the Anthropic Chat Completion API.
/// </summary>
/// <param name="betaService">The Anthropic <see cref="IBetaService"/> to use for the agent.</param>
/// <param name="options">Full set of options to configure the agent.</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 enabling logging within 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>
/// <returns>An <see cref="ChatClientAgent"/> instance backed by the Anthropic Chat Completion service.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="betaService"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
public static ChatClientAgent AsAIAgent(
this IBetaService betaService,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null,
IServiceProvider? services = null)
{
Throw.IfNull(betaService);
Throw.IfNull(options);
var chatClient = betaService.AsIChatClient();
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
}
return new ChatClientAgent(chatClient, options, loggerFactory, services);
}
}
@@ -0,0 +1,103 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Anthropic;
/// <summary>
/// Provides extension methods for the <see cref="IAnthropicClient"/> class.
/// </summary>
public static class AnthropicClientExtensions
{
/// <summary>
/// Specifies the default maximum number of tokens allowed for processing operations.
/// </summary>
public static int DefaultMaxTokens { get; set; } = 4096;
/// <summary>
/// Creates a new AI agent using the specified model and options.
/// </summary>
/// <param name="client">An Anthropic <see cref="IAnthropicClient"/> to use with the agent..</param>
/// <param name="model">The model to use for chat completions.</param>
/// <param name="instructions">The instructions for the AI agent.</param>
/// <param name="name">The name of the AI agent.</param>
/// <param name="description">The description of the AI agent.</param>
/// <param name="tools">The tools available to the AI agent.</param>
/// <param name="defaultMaxTokens">The default maximum tokens for chat completions. Defaults to <see cref="DefaultMaxTokens"/> if not provided.</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 enabling logging within 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>
/// <returns>The created <see cref="ChatClientAgent"/> AI agent.</returns>
public static ChatClientAgent AsAIAgent(
this IAnthropicClient client,
string model,
string? instructions = null,
string? name = null,
string? description = null,
IList<AITool>? tools = null,
int? defaultMaxTokens = null,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null,
IServiceProvider? services = null)
{
var options = new ChatClientAgentOptions
{
Name = name,
Description = description,
};
if (!string.IsNullOrWhiteSpace(instructions))
{
options.ChatOptions ??= new();
options.ChatOptions.Instructions = instructions;
}
if (tools is { Count: > 0 })
{
options.ChatOptions ??= new();
options.ChatOptions.Tools = tools;
}
var chatClient = client.AsIChatClient(model, defaultMaxTokens ?? DefaultMaxTokens);
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
}
return new ChatClientAgent(chatClient, options, loggerFactory, services);
}
/// <summary>
/// Creates an AI agent from an <see cref="IAnthropicClient"/> using the Anthropic Chat Completion API.
/// </summary>
/// <param name="client">An Anthropic <see cref="IAnthropicClient"/> to use with the agent..</param>
/// <param name="options">Full set of options to configure the agent.</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 enabling logging within 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>
/// <returns>An <see cref="ChatClientAgent"/> instance backed by the Anthropic Chat Completion service.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
public static ChatClientAgent AsAIAgent(
this IAnthropicClient client,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null,
IServiceProvider? services = null)
{
Throw.IfNull(client);
Throw.IfNull(options);
var chatClient = client.AsIChatClient();
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
}
return new ChatClientAgent(chatClient, options, loggerFactory, services);
}
}
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable CA1812
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Anthropic;
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
internal sealed partial class AnthropicClientJsonContext : JsonSerializerContext;
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsReleaseCandidate>false</IsReleaseCandidate>
<ImplicitUsings>enable</ImplicitUsings>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Anthropic" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Anthropic Agents</Title>
<Description>Provides Microsoft Agent Framework support for Anthropic Agents.</Description>
</PropertyGroup>
</Project>
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<ItemGroup>
<PackageReference Include="Azure.AI.Agents.Persistent" />
<PackageReference Include="Microsoft.Extensions.AI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework AzureAI Persistent Agents</Title>
<Description>Provides Microsoft Agent Framework support for Azure AI Persistent Agents.</Description>
</PropertyGroup>
</Project>
@@ -0,0 +1,437 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Azure.AI.Agents.Persistent;
/// <summary>
/// Provides extension methods for <see cref="PersistentAgentsClient"/>.
/// </summary>
public static class PersistentAgentsClientExtensions
{
/// <summary>
/// Gets a runnable agent instance from the provided response containing persistent agent metadata.
/// </summary>
/// <param name="persistentAgentsClient">The client used to interact with persistent agents. Cannot be <see langword="null"/>.</param>
/// <param name="persistentAgentResponse">The response containing the persistent agent to be converted. Cannot be <see langword="null"/>.</param>
/// <param name="chatOptions">The default <see cref="ChatOptions"/> to use when interacting with the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> 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>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the persistent agent.</returns>
[Obsolete("Please use the latest Foundry Agents service via the Microsoft.Agents.AI.AzureAI package.")]
public static ChatClientAgent AsAIAgent(
this PersistentAgentsClient persistentAgentsClient,
Response<PersistentAgent> persistentAgentResponse,
ChatOptions? chatOptions = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null)
{
if (persistentAgentResponse is null)
{
throw new ArgumentNullException(nameof(persistentAgentResponse));
}
return AsAIAgent(persistentAgentsClient, persistentAgentResponse.Value, chatOptions, clientFactory, services);
}
/// <summary>
/// Gets a runnable agent instance from a <see cref="PersistentAgent"/> containing metadata about a persistent agent.
/// </summary>
/// <param name="persistentAgentsClient">The client used to interact with persistent agents. Cannot be <see langword="null"/>.</param>
/// <param name="persistentAgentMetadata">The persistent agent metadata to be converted. Cannot be <see langword="null"/>.</param>
/// <param name="chatOptions">The default <see cref="ChatOptions"/> to use when interacting with the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> 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>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the persistent agent.</returns>
[Obsolete("Please use the latest Foundry Agents service via the Microsoft.Agents.AI.AzureAI package.")]
public static ChatClientAgent AsAIAgent(
this PersistentAgentsClient persistentAgentsClient,
PersistentAgent persistentAgentMetadata,
ChatOptions? chatOptions = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null)
{
if (persistentAgentMetadata is null)
{
throw new ArgumentNullException(nameof(persistentAgentMetadata));
}
if (persistentAgentsClient is null)
{
throw new ArgumentNullException(nameof(persistentAgentsClient));
}
var chatClient = persistentAgentsClient.AsIChatClient(persistentAgentMetadata.Id);
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
}
if (!string.IsNullOrWhiteSpace(persistentAgentMetadata.Instructions) && chatOptions?.Instructions is null)
{
chatOptions ??= new ChatOptions();
chatOptions.Instructions = persistentAgentMetadata.Instructions;
}
return new ChatClientAgent(chatClient, options: new()
{
Id = persistentAgentMetadata.Id,
Name = persistentAgentMetadata.Name,
Description = persistentAgentMetadata.Description,
ChatOptions = chatOptions
}, services: services);
}
/// <summary>
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="PersistentAgentsClient"/>.
/// </summary>
/// <param name="persistentAgentsClient">The <see cref="PersistentAgentsClient"/> to create the <see cref="ChatClientAgent"/> with.</param>
/// <returns>A <see cref="ChatClientAgent"/> for the persistent agent.</returns>
/// <param name="agentId"> The ID of the server side agent to create a <see cref="ChatClientAgent"/> for.</param>
/// <param name="chatOptions">Options that should apply to all runs of the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> 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>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the persistent agent.</returns>
[Obsolete("Please use the latest Foundry Agents service via the Microsoft.Agents.AI.AzureAI package.")]
public static async Task<ChatClientAgent> GetAIAgentAsync(
this PersistentAgentsClient persistentAgentsClient,
string agentId,
ChatOptions? chatOptions = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null,
CancellationToken cancellationToken = default)
{
if (persistentAgentsClient is null)
{
throw new ArgumentNullException(nameof(persistentAgentsClient));
}
if (string.IsNullOrWhiteSpace(agentId))
{
throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId));
}
var persistentAgentResponse = await persistentAgentsClient.Administration.GetAgentAsync(agentId, cancellationToken).ConfigureAwait(false);
return persistentAgentsClient.AsAIAgent(persistentAgentResponse, chatOptions, clientFactory, services);
}
/// <summary>
/// Gets a runnable agent instance from the provided response containing persistent agent metadata.
/// </summary>
/// <param name="persistentAgentsClient">The client used to interact with persistent agents. Cannot be <see langword="null"/>.</param>
/// <param name="persistentAgentResponse">The response containing the persistent agent to be converted. Cannot be <see langword="null"/>.</param>
/// <param name="options">Full set of options to configure the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> 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>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the persistent agent.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="persistentAgentResponse"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
[Obsolete("Please use the latest Foundry Agents service via the Microsoft.Agents.AI.AzureAI package.")]
public static ChatClientAgent AsAIAgent(
this PersistentAgentsClient persistentAgentsClient,
Response<PersistentAgent> persistentAgentResponse,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null)
{
if (persistentAgentResponse is null)
{
throw new ArgumentNullException(nameof(persistentAgentResponse));
}
return AsAIAgent(persistentAgentsClient, persistentAgentResponse.Value, options, clientFactory, services);
}
/// <summary>
/// Gets a runnable agent instance from a <see cref="PersistentAgent"/> containing metadata about a persistent agent.
/// </summary>
/// <param name="persistentAgentsClient">The client used to interact with persistent agents. Cannot be <see langword="null"/>.</param>
/// <param name="persistentAgentMetadata">The persistent agent metadata to be converted. Cannot be <see langword="null"/>.</param>
/// <param name="options">Full set of options to configure the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> 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>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the persistent agent.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="persistentAgentMetadata"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
[Obsolete("Please use the latest Foundry Agents service via the Microsoft.Agents.AI.AzureAI package.")]
public static ChatClientAgent AsAIAgent(
this PersistentAgentsClient persistentAgentsClient,
PersistentAgent persistentAgentMetadata,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null)
{
if (persistentAgentMetadata is null)
{
throw new ArgumentNullException(nameof(persistentAgentMetadata));
}
if (persistentAgentsClient is null)
{
throw new ArgumentNullException(nameof(persistentAgentsClient));
}
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
var chatClient = persistentAgentsClient.AsIChatClient(persistentAgentMetadata.Id);
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
}
if (!string.IsNullOrWhiteSpace(persistentAgentMetadata.Instructions) && options.ChatOptions?.Instructions is null)
{
options.ChatOptions ??= new ChatOptions();
options.ChatOptions.Instructions = persistentAgentMetadata.Instructions;
}
var agentOptions = new ChatClientAgentOptions()
{
Id = persistentAgentMetadata.Id,
Name = options.Name ?? persistentAgentMetadata.Name,
Description = options.Description ?? persistentAgentMetadata.Description,
ChatOptions = options.ChatOptions,
AIContextProviders = options.AIContextProviders,
ChatHistoryProvider = options.ChatHistoryProvider,
UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs
};
return new ChatClientAgent(chatClient, agentOptions, services: services);
}
/// <summary>
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="PersistentAgentsClient"/>.
/// </summary>
/// <param name="persistentAgentsClient">The <see cref="PersistentAgentsClient"/> to create the <see cref="ChatClientAgent"/> with.</param>
/// <param name="agentId">The ID of the server side agent to create a <see cref="ChatClientAgent"/> for.</param>
/// <param name="options">Full set of options to configure the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> 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>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the persistent agent.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="persistentAgentsClient"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="agentId"/> is empty or whitespace.</exception>
[Obsolete("Please use the latest Foundry Agents service via the Microsoft.Agents.AI.AzureAI package.")]
public static async Task<ChatClientAgent> GetAIAgentAsync(
this PersistentAgentsClient persistentAgentsClient,
string agentId,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null,
CancellationToken cancellationToken = default)
{
if (persistentAgentsClient is null)
{
throw new ArgumentNullException(nameof(persistentAgentsClient));
}
if (string.IsNullOrWhiteSpace(agentId))
{
throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId));
}
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
var persistentAgentResponse = await persistentAgentsClient.Administration.GetAgentAsync(agentId, cancellationToken).ConfigureAwait(false);
return persistentAgentsClient.AsAIAgent(persistentAgentResponse, options, clientFactory, services);
}
/// <summary>
/// Creates a new server side agent using the provided <see cref="PersistentAgentsClient"/>.
/// </summary>
/// <param name="persistentAgentsClient">The <see cref="PersistentAgentsClient"/> to create the agent with.</param>
/// <param name="model">The model to be used by the agent.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="description">The description of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="tools">The tools to be used by the agent.</param>
/// <param name="toolResources">The resources for the tools.</param>
/// <param name="temperature">The temperature setting for the agent.</param>
/// <param name="topP">The top-p setting for the agent.</param>
/// <param name="responseFormat">The response format for the agent.</param>
/// <param name="metadata">The metadata for the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> 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>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the newly created agent.</returns>
[Obsolete("Please use the latest Foundry Agents service via the Microsoft.Agents.AI.AzureAI package.")]
public static async Task<ChatClientAgent> CreateAIAgentAsync(
this PersistentAgentsClient persistentAgentsClient,
string model,
string? name = null,
string? description = null,
string? instructions = null,
IEnumerable<ToolDefinition>? tools = null,
ToolResources? toolResources = null,
float? temperature = null,
float? topP = null,
BinaryData? responseFormat = null,
IReadOnlyDictionary<string, string>? metadata = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null,
CancellationToken cancellationToken = default)
{
if (persistentAgentsClient is null)
{
throw new ArgumentNullException(nameof(persistentAgentsClient));
}
var createPersistentAgentResponse = await persistentAgentsClient.Administration.CreateAgentAsync(
model: model,
name: name,
description: description,
instructions: instructions,
tools: tools,
toolResources: toolResources,
temperature: temperature,
topP: topP,
responseFormat: responseFormat,
metadata: metadata,
cancellationToken: cancellationToken).ConfigureAwait(false);
// Get a local proxy for the agent to work with.
return await persistentAgentsClient.GetAIAgentAsync(createPersistentAgentResponse.Value.Id, clientFactory: clientFactory, services: services, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates a new server side agent using the provided <see cref="PersistentAgentsClient"/>.
/// </summary>
/// <param name="persistentAgentsClient">The <see cref="PersistentAgentsClient"/> to create the agent with.</param>
/// <param name="model">The model to be used by the agent.</param>
/// <param name="options">Full set of options to configure the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> 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>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the newly created agent.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="persistentAgentsClient"/> or <paramref name="model"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="model"/> is empty or whitespace.</exception>
[Obsolete("Please use the latest Foundry Agents service via the Microsoft.Agents.AI.AzureAI package.")]
public static async Task<ChatClientAgent> CreateAIAgentAsync(
this PersistentAgentsClient persistentAgentsClient,
string model,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null,
CancellationToken cancellationToken = default)
{
if (persistentAgentsClient is null)
{
throw new ArgumentNullException(nameof(persistentAgentsClient));
}
if (string.IsNullOrWhiteSpace(model))
{
throw new ArgumentException($"{nameof(model)} should not be null or whitespace.", nameof(model));
}
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
var toolDefinitionsAndResources = ConvertAIToolsToToolDefinitions(options.ChatOptions?.Tools);
var createPersistentAgentResponse = await persistentAgentsClient.Administration.CreateAgentAsync(
model: model,
name: options.Name,
description: options.Description,
instructions: options.ChatOptions?.Instructions,
tools: toolDefinitionsAndResources.ToolDefinitions,
toolResources: toolDefinitionsAndResources.ToolResources,
temperature: null,
topP: null,
responseFormat: null,
metadata: null,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (options.ChatOptions?.Tools is { Count: > 0 } && (toolDefinitionsAndResources.FunctionToolsAndOtherTools is null || options.ChatOptions.Tools.Count != toolDefinitionsAndResources.FunctionToolsAndOtherTools.Count))
{
options = options.Clone();
options.ChatOptions!.Tools = toolDefinitionsAndResources.FunctionToolsAndOtherTools;
}
// Get a local proxy for the agent to work with.
return await persistentAgentsClient.GetAIAgentAsync(createPersistentAgentResponse.Value.Id, options, clientFactory: clientFactory, services: services, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static (List<ToolDefinition>? ToolDefinitions, ToolResources? ToolResources, List<AITool>? FunctionToolsAndOtherTools) ConvertAIToolsToToolDefinitions(IList<AITool>? tools)
{
List<ToolDefinition>? toolDefinitions = null;
ToolResources? toolResources = null;
List<AITool>? functionToolsAndOtherTools = null;
if (tools is not null)
{
foreach (AITool tool in tools)
{
switch (tool)
{
case HostedCodeInterpreterTool codeTool:
toolDefinitions ??= [];
toolDefinitions.Add(new CodeInterpreterToolDefinition());
if (codeTool.Inputs is { Count: > 0 })
{
foreach (var input in codeTool.Inputs)
{
switch (input)
{
case HostedFileContent hostedFile:
// If the input is a HostedFileContent, we can use its ID directly.
toolResources ??= new();
toolResources.CodeInterpreter ??= new();
toolResources.CodeInterpreter.FileIds.Add(hostedFile.FileId);
break;
}
}
}
break;
case HostedFileSearchTool fileSearchTool:
toolDefinitions ??= [];
toolDefinitions.Add(new FileSearchToolDefinition
{
FileSearch = new() { MaxNumResults = fileSearchTool.MaximumResultCount }
});
if (fileSearchTool.Inputs is { Count: > 0 })
{
foreach (var input in fileSearchTool.Inputs)
{
switch (input)
{
case HostedVectorStoreContent hostedVectorStore:
toolResources ??= new();
toolResources.FileSearch ??= new();
toolResources.FileSearch.VectorStoreIds.Add(hostedVectorStore.VectorStoreId);
break;
}
}
}
break;
case HostedWebSearchTool webSearch when webSearch.AdditionalProperties?.TryGetValue("connectionId", out object? connectionId) is true:
toolDefinitions ??= [];
toolDefinitions.Add(new BingGroundingToolDefinition(new BingGroundingSearchToolParameters([new BingGroundingSearchConfiguration(connectionId!.ToString())])));
break;
default:
functionToolsAndOtherTools ??= [];
functionToolsAndOtherTools.Add(tool);
break;
}
}
}
return (toolDefinitions, toolResources, functionToolsAndOtherTools);
}
}
@@ -0,0 +1,3 @@
# Microsoft.Agents.AI.AzureAI.Persistent
Provides integration between the Microsoft Agent Framework and Azure AI Agents Persistent (`Azure.AI.Agents.Persistent`).
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Agents.Core.Models;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.CopilotStudio;
/// <summary>
/// Contains code to process <see cref="IActivity"/> responses from the Copilot Studio agent and convert them to <see cref="ChatMessage"/> objects.
/// </summary>
internal static class ActivityProcessor
{
public static async IAsyncEnumerable<ChatMessage> ProcessActivityAsync(IAsyncEnumerable<IActivity> activities, bool streaming, ILogger logger)
{
await foreach (IActivity activity in activities.ConfigureAwait(false))
{
// TODO: Prototype a custom AIContent type for CardActions, where the user is instructed to
// pick from a list of actions.
// The activity text doesn't make sense without the actions, as the message
// is often instructing the user to pick from the provided list of actions.
if (!string.IsNullOrWhiteSpace(activity.Text))
{
if ((activity.Type == "message" && !streaming) || (activity.Type == "typing" && streaming))
{
yield return CreateChatMessageFromActivity(activity, [new TextContent(activity.Text)]);
}
else if (logger.IsEnabled(LogLevel.Warning))
{
logger.LogWarning("Unknown activity type '{ActivityType}' received.", activity.Type);
}
}
}
}
private static ChatMessage CreateChatMessageFromActivity(IActivity activity, IEnumerable<AIContent> messageContent) =>
new(ChatRole.Assistant, [.. messageContent])
{
AuthorName = activity.From?.Name,
MessageId = activity.Id,
RawRepresentation = activity
};
}
@@ -0,0 +1,178 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.CopilotStudio.Client;
using Microsoft.Agents.Core.Models;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.CopilotStudio;
/// <summary>
/// Represents a Copilot Studio agent in the cloud.
/// </summary>
public class CopilotStudioAgent : AIAgent
{
private readonly ILogger _logger;
/// <summary>
/// The client used to interact with the Copilot Agent service.
/// </summary>
public CopilotClient Client { get; }
private static readonly AIAgentMetadata s_agentMetadata = new("copilot-studio");
/// <summary>
/// Initializes a new instance of the <see cref="CopilotStudioAgent"/> class.
/// </summary>
/// <param name="client">A client used to interact with the Copilot Agent service.</param>
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
public CopilotStudioAgent(CopilotClient client, ILoggerFactory? loggerFactory = null)
{
this.Client = client;
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<CopilotStudioAgent>();
}
/// <inheritdoc/>
protected sealed override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> new(new CopilotStudioAgentSession());
/// <summary>
/// Get a new <see cref="AgentSession"/> instance using an existing conversation id, to continue that conversation.
/// </summary>
/// <param name="conversationId">The conversation id to continue.</param>
/// <returns>A new <see cref="AgentSession"/> instance.</returns>
public ValueTask<AgentSession> CreateSessionAsync(string conversationId)
=> new(new CopilotStudioAgentSession() { ConversationId = conversationId });
/// <inheritdoc/>
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
Throw.IfNull(session);
if (session is not CopilotStudioAgentSession typedSession)
{
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(CopilotStudioAgentSession)}' can be serialized by this agent.");
}
return new(typedSession.Serialize(jsonSerializerOptions));
}
/// <inheritdoc/>
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(CopilotStudioAgentSession.Deserialize(serializedState, jsonSerializerOptions));
/// <inheritdoc/>
protected override async Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(messages);
// Ensure that we have a valid session to work with.
// If the session ID is null, we need to start a new conversation and set the session ID accordingly.
session ??= await this.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
if (session is not CopilotStudioAgentSession typedSession)
{
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(CopilotStudioAgentSession)}' can be used by this agent.");
}
typedSession.ConversationId ??= await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false);
// Invoke the Copilot Studio agent with the provided messages.
string question = string.Join("\n", messages.Select(m => m.Text));
var responseMessages = ActivityProcessor.ProcessActivityAsync(this.Client.AskQuestionAsync(question, typedSession.ConversationId, cancellationToken), streaming: false, this._logger);
var responseMessagesList = new List<ChatMessage>();
await foreach (var message in responseMessages.ConfigureAwait(false))
{
responseMessagesList.Add(message);
}
// TODO: Review list of ChatResponse properties to ensure we set all availble values.
// Setting ResponseId and MessageId end up being particularly important for streaming consumers
// so that they can tell things like response boundaries.
return new AgentResponse(responseMessagesList)
{
AgentId = this.Id,
ResponseId = responseMessagesList.LastOrDefault()?.MessageId,
};
}
/// <inheritdoc/>
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
Throw.IfNull(messages);
// Ensure that we have a valid session to work with.
// If the session ID is null, we need to start a new conversation and set the session ID accordingly.
session ??= await this.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
if (session is not CopilotStudioAgentSession typedSession)
{
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(CopilotStudioAgentSession)}' can be used by this agent.");
}
typedSession.ConversationId ??= await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false);
// Invoke the Copilot Studio agent with the provided messages.
string question = string.Join("\n", messages.Select(m => m.Text));
var responseMessages = ActivityProcessor.ProcessActivityAsync(this.Client.AskQuestionAsync(question, typedSession.ConversationId, cancellationToken), streaming: true, this._logger);
// Enumerate the response messages
await foreach (ChatMessage message in responseMessages.ConfigureAwait(false))
{
// TODO: Review list of ChatResponse properties to ensure we set all availble values.
// Setting ResponseId and MessageId end up being particularly important for streaming consumers
// so that they can tell things like response boundaries.
yield return new AgentResponseUpdate(message.Role, message.Contents)
{
AgentId = this.Id,
AdditionalProperties = message.AdditionalProperties,
AuthorName = message.AuthorName,
RawRepresentation = message.RawRepresentation,
ResponseId = message.MessageId,
MessageId = message.MessageId,
};
}
}
private async Task<string> StartNewConversationAsync(CancellationToken cancellationToken)
{
string? conversationId = null;
await foreach (IActivity activity in this.Client.StartConversationAsync(emitStartConversationEvent: true, cancellationToken).ConfigureAwait(false))
{
if (activity.Conversation is not null)
{
conversationId = activity.Conversation.Id;
}
}
if (string.IsNullOrEmpty(conversationId))
{
throw new InvalidOperationException("Failed to start a new conversation.");
}
return conversationId!;
}
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null)
=> base.GetService(serviceType, serviceKey)
?? (serviceType == typeof(CopilotClient) ? this.Client
: serviceType == typeof(AIAgentMetadata) ? s_agentMetadata
: null);
}
@@ -0,0 +1,58 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.CopilotStudio;
/// <summary>
/// Session for CopilotStudio based agents.
/// </summary>
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class CopilotStudioAgentSession : AgentSession
{
internal CopilotStudioAgentSession()
{
}
[JsonConstructor]
internal CopilotStudioAgentSession(string? conversationId, AgentSessionStateBag? stateBag) : base(stateBag ?? new())
{
this.ConversationId = conversationId;
}
/// <summary>
/// Gets the ID for the current conversation with the Copilot Studio agent.
/// </summary>
[JsonPropertyName("serviceSessionId")]
public string? ConversationId { get; internal set; }
/// <summary>
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
/// </summary>
/// <param name="jsonSerializerOptions">The JSON serialization options to use.</param>
/// <returns>A <see cref="JsonElement"/> representation of the object's state.</returns>
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
var jso = jsonSerializerOptions ?? CopilotStudioJsonUtilities.DefaultOptions;
return JsonSerializer.SerializeToElement(this, jso.GetTypeInfo(typeof(CopilotStudioAgentSession)));
}
internal static CopilotStudioAgentSession Deserialize(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null)
{
if (serializedState.ValueKind != JsonValueKind.Object)
{
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedState));
}
var jso = jsonSerializerOptions ?? CopilotStudioJsonUtilities.DefaultOptions;
return serializedState.Deserialize(jso.GetTypeInfo(typeof(CopilotStudioAgentSession))) as CopilotStudioAgentSession
?? new CopilotStudioAgentSession();
}
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay =>
$"ConversationId = {this.ConversationId}, StateBag Count = {this.StateBag.Count}";
}
@@ -0,0 +1,48 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.CopilotStudio;
/// <summary>
/// Provides utility methods and configurations for JSON serialization operations within the Copilot Studio agent implementation.
/// </summary>
internal static partial class CopilotStudioJsonUtilities
{
/// <summary>
/// Gets the default <see cref="JsonSerializerOptions"/> instance used for JSON serialization operations.
/// </summary>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
/// <summary>
/// Creates and configures the default JSON serialization options.
/// </summary>
/// <returns>The configured options.</returns>
private static JsonSerializerOptions CreateDefaultOptions()
{
// Copy the configuration from the source generated context.
JsonSerializerOptions options = new(JsonContext.Default.Options)
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
// Chain in the resolvers from both AgentAbstractionsJsonUtilities and our source generated context.
options.TypeInfoResolverChain.Clear();
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.TypeInfoResolverChain.Add(JsonContext.Default.Options.TypeInfoResolver!);
options.MakeReadOnly();
return options;
}
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
[JsonSerializable(typeof(CopilotStudioAgentSession))]
[ExcludeFromCodeCoverage]
private sealed partial class JsonContext : JsonSerializerContext;
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Agents.CopilotStudio.Client" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Copilot Studio</Title>
<Description>Provides Microsoft Agent Framework support for Copilot Studio.</Description>
</PropertyGroup>
</Project>
@@ -0,0 +1,634 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
using Microsoft.Agents.AI.CosmosNoSql;
using Microsoft.Azure.Cosmos;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides a Cosmos DB implementation of the <see cref="ChatHistoryProvider"/> abstract class.
/// </summary>
/// <remarks>
/// <para>
/// <strong>Security considerations:</strong>
/// <list type="bullet">
/// <item><description><strong>PII and sensitive data:</strong> Chat history stored in Cosmos DB may contain PII, sensitive conversation
/// content, and system instructions. Ensure the Cosmos DB account is configured with appropriate access controls, encryption at rest,
/// and network security (e.g., private endpoints, virtual network rules). The <see cref="MessageTtlSeconds"/> property can be used to
/// automatically expire messages and limit data retention.</description></item>
/// <item><description><strong>Compromised store risks:</strong> Agent Framework does not validate or filter messages loaded from the
/// store — they are accepted as-is. If the Cosmos DB store is compromised, adversarial content could be injected into the conversation
/// context, potentially influencing LLM behavior via indirect prompt injection. Altered message roles (e.g., changing <c>user</c> to
/// <c>system</c>) could escalate trust levels.</description></item>
/// <item><description><strong>Authentication:</strong> Agent Framework does not manage authentication or encryption for the Cosmos DB
/// connection — these are the responsibility of the <see cref="CosmosClient"/> configuration. Use managed identity
/// or token-based authentication where possible, and avoid embedding connection strings with keys in source code.</description></item>
/// </list>
/// </para>
/// </remarks>
[RequiresUnreferencedCode("The CosmosChatHistoryProvider uses JSON serialization which is incompatible with trimming.")]
[RequiresDynamicCode("The CosmosChatHistoryProvider uses JSON serialization which is incompatible with NativeAOT.")]
public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
{
private readonly ProviderSessionState<State> _sessionState;
private IReadOnlyList<string>? _stateKeys;
private readonly CosmosClient _cosmosClient;
private readonly Container _container;
private readonly bool _ownsClient;
private bool _disposed;
/// <summary>
/// Cached JSON serializer options for .NET 9.0 compatibility.
/// </summary>
private static readonly JsonSerializerOptions s_defaultJsonOptions = CreateDefaultJsonOptions();
private static JsonSerializerOptions CreateDefaultJsonOptions()
{
var options = new JsonSerializerOptions();
#if NET9_0_OR_GREATER
// Configure TypeInfoResolver for .NET 9.0 to enable JSON serialization
options.TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver();
#endif
return options;
}
/// <summary>
/// Gets or sets the maximum number of messages to return in a single query batch.
/// Default is 100 for optimal performance.
/// </summary>
public int MaxItemCount { get; set; } = 100;
/// <summary>
/// Gets or sets the maximum number of items per transactional batch operation.
/// Default is 100, maximum allowed by Cosmos DB is 100.
/// </summary>
public int MaxBatchSize { get; set; } = 100;
/// <summary>
/// Gets or sets the maximum number of messages to retrieve from the provider.
/// This helps prevent exceeding LLM context windows in long conversations.
/// Default is null (no limit). When set, only the most recent messages are returned.
/// </summary>
public int? MaxMessagesToRetrieve { get; set; }
/// <summary>
/// Gets or sets the Time-To-Live (TTL) in seconds for messages.
/// Default is 86400 seconds (24 hours). Set to null to disable TTL.
/// </summary>
public int? MessageTtlSeconds { get; set; } = 86400;
/// <summary>
/// Gets the database ID associated with this provider.
/// </summary>
public string DatabaseId { get; init; }
/// <summary>
/// Gets the container ID associated with this provider.
/// </summary>
public string ContainerId { get; init; }
/// <summary>
/// Initializes a new instance of the <see cref="CosmosChatHistoryProvider"/> class.
/// </summary>
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <param name="stateInitializer">A delegate that initializes the provider state on the first invocation, providing the conversation routing info (conversationId, tenantId, userId).</param>
/// <param name="ownsClient">Whether this instance owns the CosmosClient and should dispose it.</param>
/// <param name="stateKey">An optional key to use for storing the state in the <see cref="AgentSession.StateBag"/>.</param>
/// <param name="provideOutputMessageFilter">An optional filter function to apply to messages when retrieving them from the chat history.</param>
/// <param name="storeInputRequestMessageFilter">An optional filter function to apply to request messages before storing them in the chat history. If not set, defaults to excluding messages with source type <see cref="AgentRequestMessageSourceType.ChatHistory"/>.</param>
/// <param name="storeInputResponseMessageFilter">An optional filter function to apply to response messages before storing them in the chat history. If not set, defaults to storing all response messages.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> or <paramref name="stateInitializer"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
public CosmosChatHistoryProvider(
CosmosClient cosmosClient,
string databaseId,
string containerId,
Func<AgentSession?, State> stateInitializer,
bool ownsClient = false,
string? stateKey = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideOutputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputRequestMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputResponseMessageFilter = null)
: base(provideOutputMessageFilter, storeInputRequestMessageFilter, storeInputResponseMessageFilter)
{
this._sessionState = new ProviderSessionState<State>(
Throw.IfNull(stateInitializer),
stateKey ?? this.GetType().Name);
this._cosmosClient = Throw.IfNull(cosmosClient);
CosmosOptionsHelper.EnsureApplicationName(this._cosmosClient, nameof(CosmosChatHistoryProvider));
this.DatabaseId = Throw.IfNullOrWhitespace(databaseId);
this.ContainerId = Throw.IfNullOrWhitespace(containerId);
this._container = this._cosmosClient.GetContainer(databaseId, containerId);
this._ownsClient = ownsClient;
}
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= [this._sessionState.StateKey];
/// <summary>
/// Initializes a new instance of the <see cref="CosmosChatHistoryProvider"/> class using a connection string.
/// </summary>
/// <param name="connectionString">The Cosmos DB connection string.</param>
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <param name="stateInitializer">A delegate that initializes the provider state on the first invocation.</param>
/// <param name="stateKey">An optional key to use for storing the state in the <see cref="AgentSession.StateBag"/>.</param>
/// <param name="provideOutputMessageFilter">An optional filter function to apply to messages when retrieving them from the chat history.</param>
/// <param name="storeInputRequestMessageFilter">An optional filter function to apply to request messages before storing them in the chat history. If not set, defaults to excluding messages with source type <see cref="AgentRequestMessageSourceType.ChatHistory"/>.</param>
/// <param name="storeInputResponseMessageFilter">An optional filter function to apply to response messages before storing them in the chat history. If not set, defaults to storing all response messages.</param>
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
public CosmosChatHistoryProvider(
string connectionString,
string databaseId,
string containerId,
Func<AgentSession?, State> stateInitializer,
string? stateKey = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideOutputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputRequestMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputResponseMessageFilter = null)
: this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString), CosmosOptionsHelper.CreateOptions(nameof(CosmosChatHistoryProvider))), databaseId, containerId, stateInitializer, ownsClient: true, stateKey, provideOutputMessageFilter, storeInputRequestMessageFilter, storeInputResponseMessageFilter)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="CosmosChatHistoryProvider"/> class using TokenCredential for authentication.
/// </summary>
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <param name="stateInitializer">A delegate that initializes the provider state on the first invocation.</param>
/// <param name="stateKey">An optional key to use for storing the state in the <see cref="AgentSession.StateBag"/>.</param>
/// <param name="provideOutputMessageFilter">An optional filter function to apply to messages when retrieving them from the chat history.</param>
/// <param name="storeInputRequestMessageFilter">An optional filter function to apply to request messages before storing them in the chat history. If not set, defaults to excluding messages with source type <see cref="AgentRequestMessageSourceType.ChatHistory"/>.</param>
/// <param name="storeInputResponseMessageFilter">An optional filter function to apply to response messages before storing them in the chat history. If not set, defaults to storing all response messages.</param>
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
public CosmosChatHistoryProvider(
string accountEndpoint,
TokenCredential tokenCredential,
string databaseId,
string containerId,
Func<AgentSession?, State> stateInitializer,
string? stateKey = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? provideOutputMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputRequestMessageFilter = null,
Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? storeInputResponseMessageFilter = null)
: this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential), CosmosOptionsHelper.CreateOptions(nameof(CosmosChatHistoryProvider))), databaseId, containerId, stateInitializer, ownsClient: true, stateKey, provideOutputMessageFilter, storeInputRequestMessageFilter, storeInputResponseMessageFilter)
{
}
/// <summary>
/// Determines whether hierarchical partitioning should be used based on the state.
/// </summary>
private static bool UseHierarchicalPartitioning(State state) =>
state.TenantId is not null && state.UserId is not null;
/// <summary>
/// Builds the partition key from the state.
/// </summary>
private static PartitionKey BuildPartitionKey(State state)
{
if (UseHierarchicalPartitioning(state))
{
return new PartitionKeyBuilder()
.Add(state.TenantId)
.Add(state.UserId)
.Add(state.ConversationId)
.Build();
}
return new PartitionKey(state.ConversationId);
}
/// <inheritdoc />
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
if (this._disposed)
{
throw new ObjectDisposedException(this.GetType().FullName);
}
#pragma warning restore CA1513
var state = this._sessionState.GetOrInitializeState(context.Session);
var partitionKey = BuildPartitionKey(state);
// Fetch most recent messages in descending order when limit is set, then reverse to ascending
var orderDirection = this.MaxMessagesToRetrieve.HasValue ? "DESC" : "ASC";
var query = new QueryDefinition($"SELECT * FROM c WHERE c.conversationId = @conversationId AND c.type = @type ORDER BY c.timestamp {orderDirection}")
.WithParameter("@conversationId", state.ConversationId)
.WithParameter("@type", "ChatMessage");
var iterator = this._container.GetItemQueryIterator<CosmosMessageDocument>(query, requestOptions: new QueryRequestOptions
{
PartitionKey = partitionKey,
MaxItemCount = this.MaxItemCount // Configurable query performance
});
var messages = new List<ChatMessage>();
while (iterator.HasMoreResults)
{
var response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false);
foreach (var document in response)
{
if (this.MaxMessagesToRetrieve.HasValue && messages.Count >= this.MaxMessagesToRetrieve.Value)
{
break;
}
if (!string.IsNullOrEmpty(document.Message))
{
var message = JsonSerializer.Deserialize<ChatMessage>(document.Message, s_defaultJsonOptions);
if (message != null)
{
messages.Add(message);
}
}
}
if (this.MaxMessagesToRetrieve.HasValue && messages.Count >= this.MaxMessagesToRetrieve.Value)
{
break;
}
}
// If we fetched in descending order (most recent first), reverse to ascending order
if (this.MaxMessagesToRetrieve.HasValue)
{
messages.Reverse();
}
return messages;
}
/// <inheritdoc />
protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
if (this._disposed)
{
throw new ObjectDisposedException(this.GetType().FullName);
}
#pragma warning restore CA1513
var state = this._sessionState.GetOrInitializeState(context.Session);
var messageList = context.RequestMessages.Concat(context.ResponseMessages ?? []).ToList();
if (messageList.Count == 0)
{
return;
}
var partitionKey = BuildPartitionKey(state);
// Use transactional batch for atomic operations
if (messageList.Count > 1)
{
await this.AddMessagesInBatchAsync(partitionKey, state, messageList, cancellationToken).ConfigureAwait(false);
}
else
{
await this.AddSingleMessageAsync(partitionKey, state, messageList.First(), cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Adds multiple messages using transactional batch operations for atomicity.
/// </summary>
private async Task AddMessagesInBatchAsync(PartitionKey partitionKey, State state, List<ChatMessage> messages, CancellationToken cancellationToken)
{
var currentTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
// Process messages in optimal batch sizes
for (int i = 0; i < messages.Count; i += this.MaxBatchSize)
{
var batchMessages = messages.Skip(i).Take(this.MaxBatchSize).ToList();
await this.ExecuteBatchOperationAsync(partitionKey, state, batchMessages, currentTimestamp, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Executes a single batch operation with enhanced error handling.
/// Cosmos SDK handles throttling (429) retries automatically.
/// </summary>
private async Task ExecuteBatchOperationAsync(PartitionKey partitionKey, State state, List<ChatMessage> messages, long timestamp, CancellationToken cancellationToken)
{
// Create all documents upfront for validation and batch operation
var documents = new List<CosmosMessageDocument>(messages.Count);
foreach (var message in messages)
{
documents.Add(this.CreateMessageDocument(state, message, timestamp));
}
// Defensive check: Verify all messages share the same partition key values
// In hierarchical partitioning, this means same tenantId, userId, and sessionId
// In simple partitioning, this means same conversationId
if (documents.Count > 0)
{
if (UseHierarchicalPartitioning(state))
{
// Verify all documents have matching hierarchical partition key components
var firstDoc = documents[0];
if (!documents.All(d => d.TenantId == firstDoc.TenantId && d.UserId == firstDoc.UserId && d.SessionId == firstDoc.SessionId))
{
throw new InvalidOperationException("All messages in a batch must share the same partition key values (tenantId, userId, sessionId).");
}
}
else
{
// Verify all documents have matching conversationId
var firstConversationId = documents[0].ConversationId;
if (!documents.All(d => d.ConversationId == firstConversationId))
{
throw new InvalidOperationException("All messages in a batch must share the same partition key value (conversationId).");
}
}
}
// All messages in this store share the same partition key by design
// Transactional batches require all items to share the same partition key
var batch = this._container.CreateTransactionalBatch(partitionKey);
foreach (var document in documents)
{
batch.CreateItem(document);
}
try
{
var response = await batch.ExecuteAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Batch operation failed with status: {response.StatusCode}. Details: {response.ErrorMessage}");
}
}
catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.RequestEntityTooLarge)
{
// If batch is too large, split into smaller batches
if (messages.Count == 1)
{
// Can't split further, use single operation
await this.AddSingleMessageAsync(partitionKey, state, messages[0], cancellationToken).ConfigureAwait(false);
return;
}
// Split the batch in half and retry
var midpoint = messages.Count / 2;
var firstHalf = messages.Take(midpoint).ToList();
var secondHalf = messages.Skip(midpoint).ToList();
await this.ExecuteBatchOperationAsync(partitionKey, state, firstHalf, timestamp, cancellationToken).ConfigureAwait(false);
await this.ExecuteBatchOperationAsync(partitionKey, state, secondHalf, timestamp, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Adds a single message to the store.
/// </summary>
private async Task AddSingleMessageAsync(PartitionKey partitionKey, State state, ChatMessage message, CancellationToken cancellationToken)
{
var document = this.CreateMessageDocument(state, message, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
try
{
await this._container.CreateItemAsync(document, partitionKey, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.RequestEntityTooLarge)
{
throw new InvalidOperationException(
"Message exceeds Cosmos DB's maximum item size limit of 2MB. " +
"Message ID: " + message.MessageId + ", Serialized size is too large. " +
"Consider reducing message content or splitting into smaller messages.",
ex);
}
}
/// <summary>
/// Creates a message document with enhanced metadata.
/// </summary>
private CosmosMessageDocument CreateMessageDocument(State state, ChatMessage message, long timestamp)
{
var useHierarchical = UseHierarchicalPartitioning(state);
return new CosmosMessageDocument
{
Id = Guid.NewGuid().ToString(),
ConversationId = state.ConversationId,
Timestamp = timestamp,
MessageId = message.MessageId,
Role = message.Role.Value,
Message = JsonSerializer.Serialize(message, s_defaultJsonOptions),
Type = "ChatMessage", // Type discriminator
Ttl = this.MessageTtlSeconds, // Configurable TTL
// Include hierarchical metadata when using hierarchical partitioning
TenantId = useHierarchical ? state.TenantId : null,
UserId = useHierarchical ? state.UserId : null,
SessionId = useHierarchical ? state.ConversationId : null
};
}
/// <summary>
/// Gets the count of messages in this conversation.
/// This is an additional utility method beyond the base contract.
/// </summary>
/// <param name="session">The agent session to get state from.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of messages in the conversation.</returns>
public async Task<int> GetMessageCountAsync(AgentSession? session, CancellationToken cancellationToken = default)
{
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
if (this._disposed)
{
throw new ObjectDisposedException(this.GetType().FullName);
}
#pragma warning restore CA1513
var state = this._sessionState.GetOrInitializeState(session);
var partitionKey = BuildPartitionKey(state);
// Efficient count query
var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.conversationId = @conversationId AND c.type = @type")
.WithParameter("@conversationId", state.ConversationId)
.WithParameter("@type", "ChatMessage");
var iterator = this._container.GetItemQueryIterator<int>(query, requestOptions: new QueryRequestOptions
{
PartitionKey = partitionKey
});
// COUNT queries always return a result
var response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false);
return response.FirstOrDefault();
}
/// <summary>
/// Deletes all messages in this conversation.
/// This is an additional utility method beyond the base contract.
/// </summary>
/// <param name="session">The agent session to get state from.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of messages deleted.</returns>
public async Task<int> ClearMessagesAsync(AgentSession? session, CancellationToken cancellationToken = default)
{
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
if (this._disposed)
{
throw new ObjectDisposedException(this.GetType().FullName);
}
#pragma warning restore CA1513
var state = this._sessionState.GetOrInitializeState(session);
var partitionKey = BuildPartitionKey(state);
// Batch delete for efficiency
var query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE c.conversationId = @conversationId AND c.type = @type")
.WithParameter("@conversationId", state.ConversationId)
.WithParameter("@type", "ChatMessage");
var iterator = this._container.GetItemQueryIterator<string>(query, requestOptions: new QueryRequestOptions
{
PartitionKey = partitionKey,
MaxItemCount = this.MaxItemCount
});
var deletedCount = 0;
while (iterator.HasMoreResults)
{
var response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false);
var batch = this._container.CreateTransactionalBatch(partitionKey);
var batchItemCount = 0;
foreach (var itemId in response)
{
if (!string.IsNullOrEmpty(itemId))
{
batch.DeleteItem(itemId);
batchItemCount++;
deletedCount++;
}
}
if (batchItemCount > 0)
{
await batch.ExecuteAsync(cancellationToken).ConfigureAwait(false);
}
}
return deletedCount;
}
/// <inheritdoc />
public void Dispose()
{
if (!this._disposed)
{
if (this._ownsClient)
{
this._cosmosClient?.Dispose();
}
this._disposed = true;
}
}
/// <summary>
/// Represents the per-session state of a <see cref="CosmosChatHistoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
/// </summary>
public sealed class State
{
/// <summary>
/// Initializes a new instance of the <see cref="State"/> class.
/// </summary>
/// <param name="conversationId">The unique identifier for this conversation thread.</param>
/// <param name="tenantId">Optional tenant identifier for hierarchical partitioning.</param>
/// <param name="userId">Optional user identifier for hierarchical partitioning.</param>
public State(string conversationId, string? tenantId = null, string? userId = null)
{
this.ConversationId = Throw.IfNullOrWhitespace(conversationId);
this.TenantId = tenantId;
this.UserId = userId;
}
/// <summary>
/// Gets the conversation ID associated with this state.
/// </summary>
public string ConversationId { get; }
/// <summary>
/// Gets the tenant identifier for hierarchical partitioning, if any.
/// </summary>
public string? TenantId { get; }
/// <summary>
/// Gets the user identifier for hierarchical partitioning, if any.
/// </summary>
public string? UserId { get; }
}
/// <summary>
/// Represents a document stored in Cosmos DB for chat messages.
/// </summary>
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by Cosmos DB operations")]
private sealed class CosmosMessageDocument
{
[Newtonsoft.Json.JsonProperty("id")]
public string Id { get; set; } = string.Empty;
[Newtonsoft.Json.JsonProperty("conversationId")]
public string ConversationId { get; set; } = string.Empty;
[Newtonsoft.Json.JsonProperty("timestamp")]
public long Timestamp { get; set; }
[Newtonsoft.Json.JsonProperty("messageId")]
public string? MessageId { get; set; }
[Newtonsoft.Json.JsonProperty("role")]
public string? Role { get; set; }
[Newtonsoft.Json.JsonProperty("message")]
public string Message { get; set; } = string.Empty;
[Newtonsoft.Json.JsonProperty("type")]
public string Type { get; set; } = string.Empty;
// Omit "ttl" from the document when null so Cosmos DB leaves TTL unset (disabled) instead of
// rejecting the write. Cosmos requires ttl to be a positive integer or -1; a literal null is
// invalid, so serializing MessageTtlSeconds = null must drop the property entirely.
[Newtonsoft.Json.JsonProperty("ttl", NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
public int? Ttl { get; set; }
/// <summary>
/// Tenant ID for hierarchical partitioning scenarios (optional).
/// </summary>
[Newtonsoft.Json.JsonProperty("tenantId")]
public string? TenantId { get; set; }
/// <summary>
/// User ID for hierarchical partitioning scenarios (optional).
/// </summary>
[Newtonsoft.Json.JsonProperty("userId")]
public string? UserId { get; set; }
/// <summary>
/// Session ID for hierarchical partitioning scenarios (same as ConversationId for compatibility).
/// </summary>
[Newtonsoft.Json.JsonProperty("sessionId")]
public string? SessionId { get; set; }
}
}
@@ -0,0 +1,277 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Azure.Core;
using Microsoft.Agents.AI.CosmosNoSql;
using Microsoft.Azure.Cosmos;
using Microsoft.Shared.Diagnostics;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
/// <summary>
/// Provides a Cosmos DB implementation of the <see cref="JsonCheckpointStore"/> abstract class.
/// </summary>
/// <typeparam name="T">The type of objects to store as checkpoint values.</typeparam>
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
public class CosmosCheckpointStore<T> : JsonCheckpointStore, IDisposable
{
private readonly CosmosClient _cosmosClient;
private readonly Container _container;
private readonly bool _ownsClient;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="CosmosCheckpointStore{T}"/> class using a connection string.
/// </summary>
/// <param name="connectionString">The Cosmos DB connection string.</param>
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
public CosmosCheckpointStore(string connectionString, string databaseId, string containerId)
{
var cosmosClientOptions = CosmosOptionsHelper.CreateOptions(nameof(CosmosCheckpointStore));
this._cosmosClient = new CosmosClient(Throw.IfNullOrWhitespace(connectionString), cosmosClientOptions);
this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId));
this._ownsClient = true;
}
/// <summary>
/// Initializes a new instance of the <see cref="CosmosCheckpointStore{T}"/> class using a TokenCredential for authentication.
/// </summary>
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
public CosmosCheckpointStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId)
{
var cosmosClientOptions = CosmosOptionsHelper.CreateOptions(nameof(CosmosCheckpointStore));
cosmosClientOptions.SerializerOptions = new CosmosSerializationOptions
{
PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
};
this._cosmosClient = new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential), cosmosClientOptions);
this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId));
this._ownsClient = true;
}
/// <summary>
/// Initializes a new instance of the <see cref="CosmosCheckpointStore{T}"/> class using an existing <see cref="CosmosClient"/>.
/// </summary>
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="cosmosClient"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
public CosmosCheckpointStore(CosmosClient cosmosClient, string databaseId, string containerId)
{
this._cosmosClient = Throw.IfNull(cosmosClient);
CosmosOptionsHelper.EnsureApplicationName(this._cosmosClient, nameof(CosmosCheckpointStore));
this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId));
this._ownsClient = false;
}
/// <summary>
/// Gets the identifier of the Cosmos DB database.
/// </summary>
public string DatabaseId => this._container.Database.Id;
/// <summary>
/// Gets the identifier of the Cosmos DB container.
/// </summary>
public string ContainerId => this._container.Id;
/// <inheritdoc />
public override async ValueTask<CheckpointInfo> CreateCheckpointAsync(string sessionId, JsonElement value, CheckpointInfo? parent = null)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(sessionId));
}
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
if (this._disposed)
{
throw new ObjectDisposedException(this.GetType().FullName);
}
#pragma warning restore CA1513
var checkpointId = Guid.NewGuid().ToString("N");
var checkpointInfo = new CheckpointInfo(sessionId, checkpointId);
var document = new CosmosCheckpointDocument
{
Id = $"{sessionId}_{checkpointId}",
SessionId = sessionId,
CheckpointId = checkpointId,
Value = JToken.Parse(value.GetRawText()),
ParentCheckpointId = parent?.CheckpointId,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
await this._container.CreateItemAsync(document, new PartitionKey(sessionId)).ConfigureAwait(false);
return checkpointInfo;
}
/// <inheritdoc />
public override async ValueTask<JsonElement> RetrieveCheckpointAsync(string sessionId, CheckpointInfo key)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(sessionId));
}
if (key is null)
{
throw new ArgumentNullException(nameof(key));
}
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
if (this._disposed)
{
throw new ObjectDisposedException(this.GetType().FullName);
}
#pragma warning restore CA1513
var id = $"{sessionId}_{key.CheckpointId}";
try
{
var response = await this._container.ReadItemAsync<CosmosCheckpointDocument>(id, new PartitionKey(sessionId)).ConfigureAwait(false);
using var document = JsonDocument.Parse(response.Resource.Value.ToString());
return document.RootElement.Clone();
}
catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
throw new InvalidOperationException($"Checkpoint with ID '{key.CheckpointId}' for session '{sessionId}' not found.");
}
}
/// <inheritdoc />
public override async ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(sessionId));
}
#pragma warning disable CA1513 // Use ObjectDisposedException.ThrowIf - not available on all target frameworks
if (this._disposed)
{
throw new ObjectDisposedException(this.GetType().FullName);
}
#pragma warning restore CA1513
QueryDefinition query = withParent == null
? new QueryDefinition("SELECT c.sessionId, c.checkpointId FROM c WHERE c.sessionId = @sessionId ORDER BY c.timestamp ASC")
.WithParameter("@sessionId", sessionId)
: new QueryDefinition("SELECT c.sessionId, c.checkpointId FROM c WHERE c.sessionId = @sessionId AND c.parentCheckpointId = @parentCheckpointId ORDER BY c.timestamp ASC")
.WithParameter("@sessionId", sessionId)
.WithParameter("@parentCheckpointId", withParent.CheckpointId);
var iterator = this._container.GetItemQueryIterator<CheckpointQueryResult>(query);
var checkpoints = new List<CheckpointInfo>();
while (iterator.HasMoreResults)
{
var response = await iterator.ReadNextAsync().ConfigureAwait(false);
checkpoints.AddRange(response.Select(r => new CheckpointInfo(r.SessionId, r.CheckpointId)));
}
return checkpoints;
}
/// <inheritdoc />
public void Dispose()
{
this.Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases the unmanaged resources used by the <see cref="CosmosCheckpointStore{T}"/> and optionally releases the managed resources.
/// </summary>
/// <param name="disposing">true to release both managed and unmanaged resources; false to release only unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
if (!this._disposed)
{
if (disposing && this._ownsClient)
{
this._cosmosClient?.Dispose();
}
this._disposed = true;
}
}
/// <summary>Represents a checkpoint document stored in Cosmos DB.</summary>
internal sealed class CosmosCheckpointDocument
{
[JsonProperty("id")]
public string Id { get; set; } = string.Empty;
[JsonProperty("sessionId")]
public string SessionId { get; set; } = string.Empty;
[JsonProperty("checkpointId")]
public string CheckpointId { get; set; } = string.Empty;
[JsonProperty("value")]
public JToken Value { get; set; } = JValue.CreateNull();
[JsonProperty("parentCheckpointId")]
public string? ParentCheckpointId { get; set; }
[JsonProperty("timestamp")]
public long Timestamp { get; set; }
}
/// <summary>
/// Represents the result of a checkpoint query.
/// </summary>
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by Cosmos DB query deserialization")]
private sealed class CheckpointQueryResult
{
public string SessionId { get; set; } = string.Empty;
public string CheckpointId { get; set; } = string.Empty;
}
}
/// <summary>
/// Provides a non-generic Cosmos DB implementation of the <see cref="JsonCheckpointStore"/> abstract class.
/// </summary>
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
public sealed class CosmosCheckpointStore : CosmosCheckpointStore<JsonElement>
{
/// <inheritdoc />
public CosmosCheckpointStore(string connectionString, string databaseId, string containerId)
: base(connectionString, databaseId, containerId)
{
}
/// <inheritdoc />
public CosmosCheckpointStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId)
: base(accountEndpoint, tokenCredential, databaseId, containerId)
{
}
/// <inheritdoc />
public CosmosCheckpointStore(CosmosClient cosmosClient, string databaseId, string containerId)
: base(cosmosClient, databaseId, containerId)
{
}
}
@@ -0,0 +1,114 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Azure.Core;
using Microsoft.Azure.Cosmos;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides extension methods for integrating Cosmos DB chat message storage with the Agent Framework.
/// </summary>
public static class CosmosDBChatExtensions
{
private static readonly Func<AgentSession?, CosmosChatHistoryProvider.State> s_defaultStateInitializer =
_ => new CosmosChatHistoryProvider.State(Guid.NewGuid().ToString("N"));
/// <summary>
/// Configures the agent to use Cosmos DB for message storage with connection string authentication.
/// </summary>
/// <param name="options">The chat client agent options to configure.</param>
/// <param name="connectionString">The Cosmos DB connection string.</param>
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <param name="stateInitializer">An optional delegate that initializes the provider state on the first invocation, providing the conversation routing info (conversationId, tenantId, userId). When not provided, a new conversation ID is generated automatically.</param>
/// <returns>The configured <see cref="ChatClientAgentOptions"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="options"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
[RequiresUnreferencedCode("The CosmosChatHistoryProvider uses JSON serialization which is incompatible with trimming.")]
[RequiresDynamicCode("The CosmosChatHistoryProvider uses JSON serialization which is incompatible with NativeAOT.")]
public static ChatClientAgentOptions WithCosmosDBChatHistoryProvider(
this ChatClientAgentOptions options,
string connectionString,
string databaseId,
string containerId,
Func<AgentSession?, CosmosChatHistoryProvider.State>? stateInitializer = null)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
options.ChatHistoryProvider =
new CosmosChatHistoryProvider(connectionString, databaseId, containerId, stateInitializer ?? s_defaultStateInitializer);
return options;
}
/// <summary>
/// Configures the agent to use Cosmos DB for message storage with managed identity authentication.
/// </summary>
/// <param name="options">The chat client agent options to configure.</param>
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
/// <param name="stateInitializer">An optional delegate that initializes the provider state on the first invocation, providing the conversation routing info (conversationId, tenantId, userId). When not provided, a new conversation ID is generated automatically.</param>
/// <returns>The configured <see cref="ChatClientAgentOptions"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="options"/> or <paramref name="tokenCredential"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
[RequiresUnreferencedCode("The CosmosChatHistoryProvider uses JSON serialization which is incompatible with trimming.")]
[RequiresDynamicCode("The CosmosChatHistoryProvider uses JSON serialization which is incompatible with NativeAOT.")]
public static ChatClientAgentOptions WithCosmosDBChatHistoryProviderUsingManagedIdentity(
this ChatClientAgentOptions options,
string accountEndpoint,
string databaseId,
string containerId,
TokenCredential tokenCredential,
Func<AgentSession?, CosmosChatHistoryProvider.State>? stateInitializer = null)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
if (tokenCredential is null)
{
throw new ArgumentNullException(nameof(tokenCredential));
}
options.ChatHistoryProvider =
new CosmosChatHistoryProvider(accountEndpoint, tokenCredential, databaseId, containerId, stateInitializer ?? s_defaultStateInitializer);
return options;
}
/// <summary>
/// Configures the agent to use Cosmos DB for message storage with an existing <see cref="CosmosClient"/>.
/// </summary>
/// <param name="options">The chat client agent options to configure.</param>
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <param name="stateInitializer">An optional delegate that initializes the provider state on the first invocation, providing the conversation routing info (conversationId, tenantId, userId). When not provided, a new conversation ID is generated automatically.</param>
/// <returns>The configured <see cref="ChatClientAgentOptions"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
[RequiresUnreferencedCode("The CosmosChatHistoryProvider uses JSON serialization which is incompatible with trimming.")]
[RequiresDynamicCode("The CosmosChatHistoryProvider uses JSON serialization which is incompatible with NativeAOT.")]
public static ChatClientAgentOptions WithCosmosDBChatHistoryProvider(
this ChatClientAgentOptions options,
CosmosClient cosmosClient,
string databaseId,
string containerId,
Func<AgentSession?, CosmosChatHistoryProvider.State>? stateInitializer = null)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
options.ChatHistoryProvider =
new CosmosChatHistoryProvider(cosmosClient, databaseId, containerId, stateInitializer ?? s_defaultStateInitializer);
return options;
}
}
@@ -0,0 +1,234 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using Azure.Core;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Azure.Cosmos;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Provides extension methods for integrating Cosmos DB checkpoint storage with the Agent Framework.
/// </summary>
public static class CosmosDBWorkflowExtensions
{
/// <summary>
/// Creates a Cosmos DB checkpoint store using connection string authentication.
/// </summary>
/// <param name="connectionString">The Cosmos DB connection string.</param>
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <returns>A new instance of <see cref="CosmosCheckpointStore"/>.</returns>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
public static CosmosCheckpointStore CreateCheckpointStore(
string connectionString,
string databaseId,
string containerId)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(connectionString));
}
if (string.IsNullOrWhiteSpace(databaseId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
}
if (string.IsNullOrWhiteSpace(containerId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
}
return new CosmosCheckpointStore(connectionString, databaseId, containerId);
}
/// <summary>
/// Creates a Cosmos DB checkpoint store using managed identity authentication.
/// </summary>
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
/// <returns>A new instance of <see cref="CosmosCheckpointStore"/>.</returns>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="tokenCredential"/> is null.</exception>
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
public static CosmosCheckpointStore CreateCheckpointStoreUsingManagedIdentity(
string accountEndpoint,
string databaseId,
string containerId,
TokenCredential tokenCredential)
{
if (string.IsNullOrWhiteSpace(accountEndpoint))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(accountEndpoint));
}
if (string.IsNullOrWhiteSpace(databaseId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
}
if (string.IsNullOrWhiteSpace(containerId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
}
if (tokenCredential is null)
{
throw new ArgumentNullException(nameof(tokenCredential));
}
return new CosmosCheckpointStore(accountEndpoint, tokenCredential, databaseId, containerId);
}
/// <summary>
/// Creates a Cosmos DB checkpoint store using an existing <see cref="CosmosClient"/>.
/// </summary>
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <returns>A new instance of <see cref="CosmosCheckpointStore"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
public static CosmosCheckpointStore CreateCheckpointStore(
CosmosClient cosmosClient,
string databaseId,
string containerId)
{
if (cosmosClient is null)
{
throw new ArgumentNullException(nameof(cosmosClient));
}
if (string.IsNullOrWhiteSpace(databaseId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
}
if (string.IsNullOrWhiteSpace(containerId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
}
return new CosmosCheckpointStore(cosmosClient, databaseId, containerId);
}
/// <summary>
/// Creates a generic Cosmos DB checkpoint store using connection string authentication.
/// </summary>
/// <typeparam name="T">The type of objects to store as checkpoint values.</typeparam>
/// <param name="connectionString">The Cosmos DB connection string.</param>
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <returns>A new instance of <see cref="CosmosCheckpointStore{T}"/>.</returns>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
public static CosmosCheckpointStore<T> CreateCheckpointStore<T>(
string connectionString,
string databaseId,
string containerId)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(connectionString));
}
if (string.IsNullOrWhiteSpace(databaseId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
}
if (string.IsNullOrWhiteSpace(containerId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
}
return new CosmosCheckpointStore<T>(connectionString, databaseId, containerId);
}
/// <summary>
/// Creates a generic Cosmos DB checkpoint store using managed identity authentication.
/// </summary>
/// <typeparam name="T">The type of objects to store as checkpoint values.</typeparam>
/// <param name="accountEndpoint">The Cosmos DB account endpoint URI.</param>
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <param name="tokenCredential">The TokenCredential to use for authentication (e.g., DefaultAzureCredential, ManagedIdentityCredential).</param>
/// <returns>A new instance of <see cref="CosmosCheckpointStore{T}"/>.</returns>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="tokenCredential"/> is null.</exception>
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
public static CosmosCheckpointStore<T> CreateCheckpointStoreUsingManagedIdentity<T>(
string accountEndpoint,
string databaseId,
string containerId,
TokenCredential tokenCredential)
{
if (string.IsNullOrWhiteSpace(accountEndpoint))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(accountEndpoint));
}
if (string.IsNullOrWhiteSpace(databaseId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
}
if (string.IsNullOrWhiteSpace(containerId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
}
if (tokenCredential is null)
{
throw new ArgumentNullException(nameof(tokenCredential));
}
return new CosmosCheckpointStore<T>(accountEndpoint, tokenCredential, databaseId, containerId);
}
/// <summary>
/// Creates a generic Cosmos DB checkpoint store using an existing <see cref="CosmosClient"/>.
/// </summary>
/// <typeparam name="T">The type of objects to store as checkpoint values.</typeparam>
/// <param name="cosmosClient">The <see cref="CosmosClient"/> instance to use for Cosmos DB operations.</param>
/// <param name="databaseId">The identifier of the Cosmos DB database.</param>
/// <param name="containerId">The identifier of the Cosmos DB container.</param>
/// <returns>A new instance of <see cref="CosmosCheckpointStore{T}"/>.</returns>
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
/// <exception cref="ArgumentException">Thrown when any string parameter is null or whitespace.</exception>
[RequiresUnreferencedCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with trimming.")]
[RequiresDynamicCode("The CosmosCheckpointStore uses JSON serialization which is incompatible with NativeAOT.")]
public static CosmosCheckpointStore<T> CreateCheckpointStore<T>(
CosmosClient cosmosClient,
string databaseId,
string containerId)
{
if (cosmosClient is null)
{
throw new ArgumentNullException(nameof(cosmosClient));
}
if (string.IsNullOrWhiteSpace(databaseId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(databaseId));
}
if (string.IsNullOrWhiteSpace(containerId))
{
throw new ArgumentException("Cannot be null or whitespace", nameof(containerId));
}
return new CosmosCheckpointStore<T>(cosmosClient, databaseId, containerId);
}
}
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Reflection;
using Microsoft.Azure.Cosmos;
namespace Microsoft.Agents.AI.CosmosNoSql;
/// <summary>
/// Provides shared Cosmos DB client configuration for Agent Framework Cosmos NoSQL integrations.
/// Ensures all internally-created <see cref="CosmosClient"/> instances carry a consistent
/// <see cref="CosmosClientOptions.ApplicationName"/> for telemetry and diagnostics.
/// </summary>
internal static class CosmosOptionsHelper
{
/// <summary>
/// Maximum length allowed by the Cosmos DB .NET SDK for <see cref="CosmosClientOptions.ApplicationName"/>.
/// </summary>
private const int MaxApplicationNameLength = 64;
private static readonly string s_version = GetVersion();
/// <summary>
/// Creates a <see cref="CosmosClientOptions"/> instance pre-configured with the
/// Agent Framework application name for User-Agent identification.
/// </summary>
/// <param name="component">The fully-qualified component class name (e.g. "CosmosChatHistoryProvider").</param>
/// <returns>A new <see cref="CosmosClientOptions"/> with <see cref="CosmosClientOptions.ApplicationName"/> set.</returns>
public static CosmosClientOptions CreateOptions(string component)
{
return new CosmosClientOptions
{
ApplicationName = BuildApplicationName(component)
};
}
/// <summary>
/// Ensures the given <see cref="CosmosClient"/> has an <see cref="CosmosClientOptions.ApplicationName"/> set.
/// If the client already has a non-empty ApplicationName, it is not overridden.
/// </summary>
/// <param name="cosmosClient">The client to apply the application name to.</param>
/// <param name="component">The fully-qualified component class name (e.g. "CosmosChatHistoryProvider").</param>
public static void EnsureApplicationName(CosmosClient cosmosClient, string component)
{
if (string.IsNullOrWhiteSpace(cosmosClient.ClientOptions.ApplicationName))
{
cosmosClient.ClientOptions.ApplicationName = BuildApplicationName(component);
}
}
private static string BuildApplicationName(string component)
{
var applicationName = $"Microsoft.Agents.AI.CosmosNoSql.{component}/{s_version}";
if (applicationName.Length > MaxApplicationNameLength)
{
applicationName = applicationName.Substring(0, MaxApplicationNameLength);
}
return applicationName;
}
private static string GetVersion()
{
if (typeof(CosmosOptionsHelper).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
{
int pos = version.IndexOf('+', System.StringComparison.Ordinal);
if (pos >= 0)
{
version = version.Substring(0, pos);
}
if (version.Length > 0)
{
return version;
}
}
return "unknown";
}
}
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<RootNamespace>Microsoft.Agents.AI</RootNamespace>
<VersionSuffix>preview</VersionSuffix>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectRequiredMemberOnLegacy>true</InjectRequiredMemberOnLegacy>
<InjectCompilerFeatureRequiredOnLegacy>true</InjectCompilerFeatureRequiredOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Cosmos DB NoSQL Integration</Title>
<Description>Provides Cosmos DB NoSQL implementations for Microsoft Agent Framework storage abstractions including ChatHistoryProvider and CheckpointStore.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Cosmos" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Newtonsoft.Json" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.CosmosNoSql.UnitTests" />
</ItemGroup>
</Project>
@@ -0,0 +1,91 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Microsoft.Agents.ObjectModel;
using Microsoft.Agents.ObjectModel.Abstractions;
using Microsoft.Agents.ObjectModel.Yaml;
using Microsoft.Extensions.Configuration;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Helper methods for creating <see cref="BotElement"/> from YAML.
/// </summary>
internal static class AgentBotElementYaml
{
/// <summary>
/// Convert the given YAML text to a <see cref="GptComponentMetadata"/> model.
/// </summary>
/// <param name="text">YAML representation of the <see cref="BotElement"/> to use to create the prompt function.</param>
/// <param name="configuration">Optional <see cref="IConfiguration"/> instance which provides environment variables to the template.</param>
[RequiresDynamicCode("Calls YamlDotNet.Serialization.DeserializerBuilder.DeserializerBuilder()")]
public static GptComponentMetadata FromYaml(string text, IConfiguration? configuration = null)
{
Throw.IfNullOrEmpty(text);
using var yamlReader = new StringReader(text);
BotElement rootElement = YamlSerializer.Deserialize<BotElement>(yamlReader) ?? throw new InvalidDataException("Text does not contain a valid agent definition.");
if (rootElement is not GptComponentMetadata promptAgent)
{
throw new InvalidDataException($"Unsupported root element: {rootElement.GetType().Name}. Expected an {nameof(GptComponentMetadata)}.");
}
var botDefinition = WrapPromptAgentWithBot(promptAgent, configuration);
return botDefinition.Descendants().OfType<GptComponentMetadata>().First();
}
#region private
private sealed class AgentFeatureConfiguration : IFeatureConfiguration
{
public long GetInt64Value(string settingName, long defaultValue) => defaultValue;
public string GetStringValue(string settingName, string defaultValue) => defaultValue;
public bool IsEnvironmentFeatureEnabled(string featureName, bool defaultValue) => true;
public bool IsTenantFeatureEnabled(string featureName, bool defaultValue) => defaultValue;
}
public static BotDefinition WrapPromptAgentWithBot(this GptComponentMetadata element, IConfiguration? configuration = null)
{
var botBuilder =
new BotDefinition.Builder
{
Components =
{
new GptComponent.Builder
{
SchemaName = "default-schema",
Metadata = element.ToBuilder(),
}
}
};
if (configuration is not null)
{
foreach (var kvp in configuration.AsEnumerable().Where(kvp => kvp.Value is not null))
{
botBuilder.EnvironmentVariables.Add(new EnvironmentVariableDefinition.Builder()
{
SchemaName = kvp.Key,
Id = Guid.NewGuid(),
DisplayName = kvp.Key,
ValueComponent = new EnvironmentVariableValue.Builder()
{
Id = Guid.NewGuid(),
Value = kvp.Value!,
},
});
}
}
return botBuilder.Build();
}
#endregion
}
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.ObjectModel;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides a <see cref="PromptAgentFactory"/> which aggregates multiple agent factories.
/// </summary>
public sealed class AggregatorPromptAgentFactory : PromptAgentFactory
{
private readonly PromptAgentFactory[] _agentFactories;
/// <summary>Initializes the instance.</summary>
/// <param name="agentFactories">Ordered <see cref="PromptAgentFactory"/> instances to aggregate.</param>
/// <remarks>
/// Where multiple <see cref="PromptAgentFactory"/> instances are provided, the first factory that supports the <see cref="GptComponentMetadata"/> will be used.
/// </remarks>
public AggregatorPromptAgentFactory(params PromptAgentFactory[] agentFactories)
{
Throw.IfNullOrEmpty(agentFactories);
foreach (PromptAgentFactory agentFactory in agentFactories)
{
Throw.IfNull(agentFactory, nameof(agentFactories));
}
this._agentFactories = agentFactories;
}
/// <inheritdoc/>
public override async Task<AIAgent?> TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default)
{
Throw.IfNull(promptAgent);
foreach (var agentFactory in this._agentFactories)
{
var agent = await agentFactory.TryCreateAsync(promptAgent, cancellationToken).ConfigureAwait(false);
if (agent is not null)
{
return agent;
}
}
return null;
}
}
@@ -0,0 +1,54 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.PowerFx;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides an <see cref="PromptAgentFactory"/> which creates instances of <see cref="ChatClientAgent"/>.
/// </summary>
public sealed class ChatClientPromptAgentFactory : PromptAgentFactory
{
/// <summary>
/// Creates a new instance of the <see cref="ChatClientPromptAgentFactory"/> class.
/// </summary>
public ChatClientPromptAgentFactory(IChatClient chatClient, IList<AIFunction>? functions = null, RecalcEngine? engine = null, IConfiguration? configuration = null, ILoggerFactory? loggerFactory = null) : base(engine, configuration)
{
Throw.IfNull(chatClient);
this._chatClient = chatClient;
this._functions = functions;
this._loggerFactory = loggerFactory;
}
/// <inheritdoc/>
public override Task<AIAgent?> TryCreateAsync(GptComponentMetadata promptAgent, CancellationToken cancellationToken = default)
{
Throw.IfNull(promptAgent);
var options = new ChatClientAgentOptions()
{
Name = promptAgent.Name,
Description = promptAgent.Description,
ChatOptions = promptAgent.GetChatOptions(this.Engine, this._functions),
};
var agent = new ChatClientAgent(this._chatClient, options, this._loggerFactory);
return Task.FromResult<AIAgent?>(agent);
}
#region private
private readonly IChatClient _chatClient;
private readonly IList<AIFunction>? _functions;
private readonly ILoggerFactory? _loggerFactory;
#endregion
}
@@ -0,0 +1,56 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.PowerFx;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.ObjectModel;
/// <summary>
/// Extension methods for <see cref="BoolExpression"/>.
/// </summary>
internal static class BoolExpressionExtensions
{
/// <summary>
/// Evaluates the given <see cref="BoolExpression"/> using the provided <see cref="RecalcEngine"/>.
/// </summary>
/// <param name="expression">Expression to evaluate.</param>
/// <param name="engine">Recalc engine to use for evaluation.</param>
/// <returns>The evaluated boolean value, or null if the expression is null or cannot be evaluated.</returns>
internal static bool? Eval(this BoolExpression? expression, RecalcEngine? engine)
{
if (expression is null)
{
return null;
}
if (expression.IsLiteral)
{
return expression.LiteralValue;
}
if (engine is null)
{
return null;
}
if (expression.IsExpression)
{
return engine.Eval(expression.ExpressionText!).AsBoolean();
}
else if (expression.IsVariableReference)
{
var formulaValue = engine.Eval(expression.VariableReference!.VariableName);
if (formulaValue is BooleanValue booleanValue)
{
return booleanValue.Value;
}
if (formulaValue is StringValue stringValue && bool.TryParse(stringValue.Value, out bool result))
{
return result;
}
}
return null;
}
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.ObjectModel;
/// <summary>
/// Extension methods for <see cref="CodeInterpreterTool"/>.
/// </summary>
internal static class CodeInterpreterToolExtensions
{
/// <summary>
/// Creates a <see cref="HostedCodeInterpreterTool"/> from a <see cref="CodeInterpreterTool"/>.
/// </summary>
/// <param name="tool">Instance of <see cref="CodeInterpreterTool"/></param>
internal static HostedCodeInterpreterTool AsCodeInterpreterTool(this CodeInterpreterTool tool)
{
Throw.IfNull(tool);
return new HostedCodeInterpreterTool();
}
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.ObjectModel;
/// <summary>
/// Extension methods for <see cref="FileSearchTool"/>.
/// </summary>
internal static class FileSearchToolExtensions
{
/// <summary>
/// Create a <see cref="HostedFileSearchTool"/> from a <see cref="FileSearchTool"/>.
/// </summary>
/// <param name="tool">Instance of <see cref="FileSearchTool"/></param>
internal static HostedFileSearchTool CreateFileSearchTool(this FileSearchTool tool)
{
Throw.IfNull(tool);
return new HostedFileSearchTool()
{
MaximumResultCount = (int?)tool.MaximumResultCount?.LiteralValue,
Inputs = tool.VectorStoreIds?.LiteralValue.Select(id => (AIContent)new HostedVectorStoreContent(id)).ToList(),
};
}
}
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.ObjectModel;
/// <summary>
/// Extension methods for <see cref="InvokeClientTaskAction"/>.
/// </summary>
internal static class FunctionToolExtensions
{
/// <summary>
/// Creates a <see cref="AIFunctionDeclaration"/> from a <see cref="InvokeClientTaskAction"/>.
/// </summary>
/// <remarks>
/// If a matching function already exists in the provided list, it will be returned.
/// Otherwise, a new function declaration will be created.
/// </remarks>
/// <param name="tool">Instance of <see cref="InvokeClientTaskAction"/></param>
/// <param name="functions">Instance of <see cref="IList{AIFunction}"/></param>
internal static AITool CreateOrGetAITool(this InvokeClientTaskAction tool, IList<AIFunction>? functions)
{
Throw.IfNull(tool);
Throw.IfNull(tool.Name);
// use the tool from the provided list if it exists
if (functions is not null)
{
var function = functions.FirstOrDefault(f => tool.Matches(f));
if (function is not null)
{
return function;
}
}
return AIFunctionFactory.CreateDeclaration(
name: tool.Name,
description: tool.Description,
jsonSchema: tool.ClientActionInputSchema?.GetSchema() ?? s_defaultSchema);
}
/// <summary>
/// Checks if a <see cref="InvokeClientTaskAction"/> matches an <see cref="AITool"/>.
/// </summary>
/// <param name="tool">Instance of <see cref="InvokeClientTaskAction"/></param>
/// <param name="aiFunc">Instance of <see cref="AIFunction"/></param>
internal static bool Matches(this InvokeClientTaskAction tool, AIFunction aiFunc)
{
Throw.IfNull(tool);
Throw.IfNull(aiFunc);
return tool.Name == aiFunc.Name;
}
private static readonly JsonElement s_defaultSchema = JsonDocument.Parse("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}").RootElement;
}

Some files were not shown because too many files have changed in this diff Show More