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
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:
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Aspire.Hosting.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Describes an AI agent exposed by an agent service backend, used for entity discovery in DevUI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When added via <see cref="AgentFrameworkBuilderExtensions.WithAgentService{TSource}"/>,
|
||||
/// agent metadata is declared at the AppHost level so that the DevUI aggregator can build the
|
||||
/// entity listing without querying each backend's <c>/v1/entities</c> endpoint.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Agent services only need to expose the standard OpenAI Responses and Conversations API endpoints
|
||||
/// (<c>MapOpenAIResponses</c> and <c>MapOpenAIConversations</c>), not a custom discovery endpoint.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Id">The unique identifier for the agent, typically matching the name passed to <c>AddAIAgent</c>.</param>
|
||||
/// <param name="Description">A short description of the agent's capabilities.</param>
|
||||
public record AgentEntityInfo(string Id, string? Description = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the display name for the agent. Defaults to <see cref="Id"/> if not specified.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = Id;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the entity type. Defaults to <c>"agent"</c>.
|
||||
/// </summary>
|
||||
public string Type { get; init; } = "agent";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the framework identifier. Defaults to <c>"agent_framework"</c>.
|
||||
/// </summary>
|
||||
public string Framework { get; init; } = "agent_framework";
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Aspire.Hosting.AgentFramework;
|
||||
using Aspire.Hosting.ApplicationModel;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Aspire.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for adding Agent Framework DevUI resources to the application model.
|
||||
/// </summary>
|
||||
public static class AgentFrameworkBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a DevUI resource for testing AI agents in a distributed application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// DevUI is a web-based interface for testing and debugging AI agents using the OpenAI Responses protocol.
|
||||
/// When configured with <see cref="WithAgentService{TSource}"/>, it aggregates agents from multiple backend services
|
||||
/// and provides a unified testing interface.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The aggregator runs as an in-process reverse proxy within the AppHost, requiring no external container image.
|
||||
/// It serves the DevUI frontend from embedded resources in Microsoft.Agents.AI.DevUI when available, and
|
||||
/// falls back to proxying from the first configured backend. It aggregates entity listings from all backends.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This resource is excluded from the deployment manifest as it is intended for development use only.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
|
||||
/// <param name="name">The name to give the resource.</param>
|
||||
/// <param name="port">The host port for the DevUI web interface. If not specified, a random port will be assigned.</param>
|
||||
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var devui = builder.AddDevUI("devui")
|
||||
/// .WithAgentService(dotnetAgent)
|
||||
/// .WithAgentService(pythonAgent);
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static IResourceBuilder<DevUIResource> AddDevUI(
|
||||
this IDistributedApplicationBuilder builder,
|
||||
string name,
|
||||
int? port = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
var resource = new DevUIResource(name, port);
|
||||
|
||||
var resourceBuilder = builder.AddResource(resource)
|
||||
.ExcludeFromManifest(); // DevUI is a dev-only tool
|
||||
|
||||
// Initialize the in-process aggregator when the resource is initialized by the orchestrator
|
||||
builder.Eventing.Subscribe<InitializeResourceEvent>(resource, async (e, ct) =>
|
||||
{
|
||||
var logger = e.Logger;
|
||||
var aggregator = new DevUIAggregatorHostedService(resource, e.Services.GetRequiredService<ILoggerFactory>().CreateLogger<DevUIAggregatorHostedService>());
|
||||
|
||||
try
|
||||
{
|
||||
// Wait for dependencies (e.g. agent service backends) before starting.
|
||||
// Custom resources must manually publish BeforeResourceStartedEvent to trigger
|
||||
// the orchestrator's WaitFor mechanism.
|
||||
await e.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, e.Services), ct).ConfigureAwait(false);
|
||||
|
||||
await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
|
||||
{
|
||||
State = KnownResourceStates.Starting
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
await aggregator.StartAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Allocate the endpoint so the URL appears in the Aspire dashboard
|
||||
var endpointAnnotation = resource.Annotations
|
||||
.OfType<EndpointAnnotation>()
|
||||
.First(ea => ea.Name == DevUIResource.PrimaryEndpointName);
|
||||
|
||||
endpointAnnotation.AllocatedEndpoint = new AllocatedEndpoint(
|
||||
endpointAnnotation, "localhost", aggregator.AllocatedPort);
|
||||
|
||||
var devuiUrl = $"http://localhost:{aggregator.AllocatedPort}/devui/";
|
||||
|
||||
await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
|
||||
{
|
||||
State = KnownResourceStates.Running,
|
||||
Urls = [new UrlSnapshot("DevUI", devuiUrl, IsInternal: false)]
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
// Shut down the aggregator when the app stops
|
||||
var lifetime = e.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||
lifetime.ApplicationStopping.Register(() =>
|
||||
{
|
||||
e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
|
||||
{
|
||||
State = KnownResourceStates.Finished
|
||||
}).GetAwaiter().GetResult();
|
||||
|
||||
aggregator.StopAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
aggregator.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to start DevUI aggregator");
|
||||
|
||||
await aggregator.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
|
||||
{
|
||||
State = KnownResourceStates.FailedToStart
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
|
||||
return resourceBuilder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures DevUI to connect to an agent service backend.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each agent service should expose the OpenAI Responses and Conversations API endpoints
|
||||
/// (via <c>MapOpenAIResponses</c> and <c>MapOpenAIConversations</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <paramref name="agents"/> is provided, the aggregator builds the entity listing from
|
||||
/// these declarations without querying the backend. When not provided, a single agent named
|
||||
/// after the service resource is assumed. Agent services don't need a <c>/v1/entities</c> endpoint.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <typeparam name="TSource">The type of the agent service resource.</typeparam>
|
||||
/// <param name="builder">The DevUI resource builder.</param>
|
||||
/// <param name="agentService">The agent service resource to connect to.</param>
|
||||
/// <param name="agents">
|
||||
/// Optional list of agents declared by this backend. When provided, the aggregator uses these
|
||||
/// declarations directly. When not provided, defaults to a single agent named after the
|
||||
/// <paramref name="agentService"/> resource. The backend doesn't need to expose a
|
||||
/// <c>/v1/entities</c> endpoint in either case.
|
||||
/// </param>
|
||||
/// <param name="entityIdPrefix">
|
||||
/// An optional prefix to add to entity IDs from this backend.
|
||||
/// If not specified, the resource name will be used as the prefix.
|
||||
/// </param>
|
||||
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var writerAgent = builder.AddProject<Projects.WriterAgent>("writer-agent");
|
||||
/// var editorAgent = builder.AddProject<Projects.EditorAgent>("editor-agent");
|
||||
///
|
||||
/// builder.AddDevUI("devui")
|
||||
/// .WithAgentService(writerAgent, agents: [new("writer", "Writes short stories")])
|
||||
/// .WithAgentService(editorAgent, agents: [new("editor", "Edits and formats stories")])
|
||||
/// .WaitFor(writerAgent)
|
||||
/// .WaitFor(editorAgent);
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static IResourceBuilder<DevUIResource> WithAgentService<TSource>(
|
||||
this IResourceBuilder<DevUIResource> builder,
|
||||
IResourceBuilder<TSource> agentService,
|
||||
IReadOnlyList<AgentEntityInfo>? agents = null,
|
||||
string? entityIdPrefix = null)
|
||||
where TSource : IResourceWithEndpoints
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(agentService);
|
||||
|
||||
// Default to a single agent named after the service resource
|
||||
agents ??= [new AgentEntityInfo(agentService.Resource.Name)];
|
||||
|
||||
builder.WithAnnotation(new AgentServiceAnnotation(agentService.Resource, entityIdPrefix, agents));
|
||||
builder.WithRelationship(agentService.Resource, "agent-backend");
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Aspire.Hosting.AgentFramework;
|
||||
|
||||
namespace Aspire.Hosting.ApplicationModel;
|
||||
|
||||
/// <summary>
|
||||
/// An annotation that tracks an agent service backend referenced by a DevUI resource.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This annotation is used to configure DevUI to aggregate entities from multiple
|
||||
/// agent service backends. Each annotation represents one backend that DevUI should
|
||||
/// connect to for entity discovery and request routing.
|
||||
/// </remarks>
|
||||
public class AgentServiceAnnotation : IResourceAnnotation
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentServiceAnnotation"/> class.
|
||||
/// </summary>
|
||||
/// <param name="agentService">The agent service resource.</param>
|
||||
/// <param name="entityIdPrefix">
|
||||
/// An optional prefix to add to entity IDs from this backend to avoid conflicts.
|
||||
/// If not specified, the resource name will be used as the prefix.
|
||||
/// </param>
|
||||
/// <param name="agents">
|
||||
/// Optional list of agents declared by this backend. When provided, the aggregator builds the entity
|
||||
/// listing directly from these declarations instead of querying the backend's <c>/v1/entities</c> endpoint.
|
||||
/// </param>
|
||||
public AgentServiceAnnotation(IResource agentService, string? entityIdPrefix = null, IReadOnlyList<AgentEntityInfo>? agents = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentService);
|
||||
|
||||
this.AgentService = agentService;
|
||||
this.EntityIdPrefix = entityIdPrefix;
|
||||
this.Agents = agents ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent service resource that exposes AI agents.
|
||||
/// </summary>
|
||||
public IResource AgentService { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the prefix to use for entity IDs from this backend.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <c>null</c>, the resource name will be used as the prefix.
|
||||
/// Entity IDs will be formatted as "{prefix}/{entityId}" to ensure uniqueness
|
||||
/// across multiple agent backends.
|
||||
/// </remarks>
|
||||
public string? EntityIdPrefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of agents declared by this backend.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When non-empty, the DevUI aggregator uses these declarations to build the entity listing
|
||||
/// without querying the backend. When empty, the aggregator falls back to calling
|
||||
/// <c>GET /v1/entities</c> on the backend for discovery.
|
||||
/// </remarks>
|
||||
public IReadOnlyList<AgentEntityInfo> Agents { get; }
|
||||
}
|
||||
+36
@@ -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
|
||||
@@ -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>
|
||||
```
|
||||
+37
@@ -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>
|
||||
```
|
||||
@@ -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>
|
||||
+26
@@ -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>
|
||||
+437
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
@@ -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
Reference in New Issue
Block a user