mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Durable extension: initial src and unit tests (#1900)
This commit is contained in:
committed by
GitHub
Unverified
parent
1d5677be11
commit
5686a009fb
@@ -93,6 +93,19 @@
|
||||
<PackageVersion Include="Microsoft.Bot.ObjectModel.Json" Version="1.2025.1003.2" />
|
||||
<PackageVersion Include="Microsoft.Bot.ObjectModel.PowerFx" Version="1.2025.1003.2" />
|
||||
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.4.0" />
|
||||
<!-- Durable Task -->
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.16.2" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.16.2-preview.1" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.16.2" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.16.2-preview.1" />
|
||||
<!-- Azure Functions -->
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.0.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.9.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.5" />
|
||||
<!-- Community -->
|
||||
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
|
||||
<!-- Test -->
|
||||
|
||||
@@ -274,8 +274,10 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
|
||||
@@ -299,7 +301,9 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj" Id="2a1c544d-237d-4436-8732-ba0c447ac06b" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for the <see cref="AIAgent"/> class.
|
||||
/// </summary>
|
||||
public static class AIAgentExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts an AIAgent to a durable agent proxy.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent to convert.</param>
|
||||
/// <param name="services">The service provider.</param>
|
||||
/// <returns>The durable agent proxy.</returns>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when the agent is a DurableAIAgent instance or if the agent has no name.
|
||||
/// </exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown if <paramref name="services"/> does not contain an <see cref="IDurableAgentClient"/>.
|
||||
/// </exception>
|
||||
public static AIAgent AsDurableAgentProxy(this AIAgent agent, IServiceProvider services)
|
||||
{
|
||||
// Don't allow this method to be used on DurableAIAgent instances.
|
||||
if (agent is DurableAIAgent)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{nameof(DurableAIAgent)} instances cannot be converted to a durable agent proxy.",
|
||||
nameof(agent));
|
||||
}
|
||||
|
||||
string agentName = agent.Name ?? throw new ArgumentException("Agent must have a name.", nameof(agent));
|
||||
IDurableAgentClient agentClient = services.GetRequiredService<IDurableAgentClient>();
|
||||
return new DurableAIAgentProxy(agentName, agentClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Entities;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
internal class AgentEntity(IServiceProvider services, CancellationToken cancellationToken = default) : TaskEntity<DurableAgentState>
|
||||
{
|
||||
private readonly IServiceProvider _services = services;
|
||||
private readonly DurableTaskClient _client = services.GetRequiredService<DurableTaskClient>();
|
||||
private readonly ILoggerFactory _loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
||||
private readonly IAgentResponseHandler? _messageHandler = services.GetService<IAgentResponseHandler>();
|
||||
private readonly CancellationToken _cancellationToken = cancellationToken != default
|
||||
? cancellationToken
|
||||
: services.GetService<IHostApplicationLifetime>()?.ApplicationStopping ?? CancellationToken.None;
|
||||
|
||||
public async Task<AgentRunResponse> RunAgentAsync(RunRequest request)
|
||||
{
|
||||
AgentSessionId sessionId = this.Context.Id;
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents =
|
||||
this._services.GetRequiredService<IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>>();
|
||||
if (!agents.TryGetValue(sessionId.Name, out Func<IServiceProvider, AIAgent>? agentFactory))
|
||||
{
|
||||
throw new InvalidOperationException($"Agent '{sessionId.Name}' not found");
|
||||
}
|
||||
|
||||
AIAgent agent = agentFactory(this._services);
|
||||
EntityAgentWrapper agentWrapper = new(agent, this.Context, request, this._services);
|
||||
|
||||
// Logger category is Microsoft.DurableTask.Agents.{agentName}.{sessionId}
|
||||
ILogger logger = this._loggerFactory.CreateLogger($"Microsoft.DurableTask.Agents.{agent.Name}.{sessionId.Key}");
|
||||
|
||||
if (request.Messages.Count == 0)
|
||||
{
|
||||
logger.LogInformation("Ignoring empty request");
|
||||
}
|
||||
|
||||
// TODO: Get state from optional state store
|
||||
DurableAgentState state = this.State;
|
||||
|
||||
foreach (ChatMessage msg in request.Messages)
|
||||
{
|
||||
logger.LogAgentRequest(sessionId, msg.Role, msg.Text);
|
||||
state.AddChatMessage(msg);
|
||||
}
|
||||
|
||||
// Set the current agent context for the duration of the agent run. This will be exposed
|
||||
// to any tools that are invoked by the agent.
|
||||
DurableAgentContext agentContext = new(
|
||||
entityContext: this.Context,
|
||||
client: this._client,
|
||||
lifetime: this._services.GetRequiredService<IHostApplicationLifetime>(),
|
||||
services: this._services);
|
||||
DurableAgentContext.SetCurrent(agentContext);
|
||||
|
||||
try
|
||||
{
|
||||
// Start the agent response stream
|
||||
IAsyncEnumerable<AgentRunResponseUpdate> responseStream = agentWrapper.RunStreamingAsync(
|
||||
state.EnumerateChatMessages(),
|
||||
agentWrapper.GetNewThread(),
|
||||
options: null,
|
||||
this._cancellationToken);
|
||||
|
||||
AgentRunResponse response;
|
||||
if (this._messageHandler is null)
|
||||
{
|
||||
// If no message handler is provided, we can just get the full response at once.
|
||||
// This is expected to be the common case for non-interactive agents.
|
||||
response = await responseStream.ToAgentRunResponseAsync(this._cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
List<AgentRunResponseUpdate> responseUpdates = [];
|
||||
|
||||
// To support interactive chat agents, we need to stream the responses to an IAgentMessageHandler.
|
||||
// The user-provided message handler can be implemented to send the responses to the user.
|
||||
// We assume that only non-empty text updates are useful for the user.
|
||||
async IAsyncEnumerable<AgentRunResponseUpdate> StreamResultsAsync()
|
||||
{
|
||||
await foreach (AgentRunResponseUpdate update in responseStream)
|
||||
{
|
||||
// We need the full response further down, so we piece it together as we go.
|
||||
responseUpdates.Add(update);
|
||||
|
||||
// Yield the update to the message handler.
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
await this._messageHandler.OnStreamingResponseUpdateAsync(StreamResultsAsync(), this._cancellationToken);
|
||||
response = responseUpdates.ToAgentRunResponse();
|
||||
}
|
||||
|
||||
// Persist the agent response to the entity state for client polling
|
||||
state.AddAgentResponse(response, request.CorrelationId);
|
||||
|
||||
string responseText = response.Text;
|
||||
|
||||
if (!string.IsNullOrEmpty(responseText))
|
||||
{
|
||||
logger.LogAgentResponse(
|
||||
sessionId,
|
||||
response.Messages.FirstOrDefault()?.Role ?? ChatRole.Assistant,
|
||||
responseText,
|
||||
response.Usage?.InputTokenCount,
|
||||
response.Usage?.OutputTokenCount,
|
||||
response.Usage?.TotalTokenCount);
|
||||
}
|
||||
|
||||
this.UpdateEntityState(state);
|
||||
|
||||
return response;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clear the current agent context
|
||||
DurableAgentContext.ClearCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEntityState(DurableAgentState state)
|
||||
{
|
||||
// This method is called to update the state of the entity.
|
||||
// It can be used to persist the state to a durable store if needed.
|
||||
this.State = state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Client.Entities;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a handle for a running agent request that can be used to retrieve the response.
|
||||
/// </summary>
|
||||
internal sealed class AgentRunHandle
|
||||
{
|
||||
private readonly DurableTaskClient _client;
|
||||
|
||||
internal AgentRunHandle(DurableTaskClient client, AgentSessionId sessionId, string correlationId)
|
||||
{
|
||||
this._client = client;
|
||||
this.SessionId = sessionId;
|
||||
this.CorrelationId = correlationId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the correlation ID for this request.
|
||||
/// </summary>
|
||||
public string CorrelationId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the session ID for this request.
|
||||
/// </summary>
|
||||
public AgentSessionId SessionId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Reads the agent response for this request by polling the entity state until the response is found.
|
||||
/// Uses an exponential backoff polling strategy with a maximum interval of 1 second.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The agent response corresponding to this request.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the response is not found after polling.</exception>
|
||||
public async Task<AgentRunResponse> ReadAgentResponseAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
TimeSpan pollInterval = TimeSpan.FromMilliseconds(50); // Start with 50ms
|
||||
TimeSpan maxPollInterval = TimeSpan.FromSeconds(3); // Maximum 3 seconds
|
||||
|
||||
while (true)
|
||||
{
|
||||
// Poll the entity state for responses
|
||||
EntityMetadata<DurableAgentState>? entityResponse = await this._client.Entities.GetEntityAsync<DurableAgentState>(
|
||||
this.SessionId,
|
||||
cancellation: cancellationToken);
|
||||
DurableAgentState? state = entityResponse?.State;
|
||||
|
||||
// Look for an agent response with matching CorrelationId
|
||||
if (state is not null && state.TryGetAgentResponse(this.CorrelationId, out AgentRunResponse? response))
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
// Wait before polling again with exponential backoff
|
||||
await Task.Delay(pollInterval, cancellationToken);
|
||||
|
||||
// Double the poll interval, but cap it at the maximum
|
||||
pollInterval = TimeSpan.FromMilliseconds(Math.Min(pollInterval.TotalMilliseconds * 2, maxPollInterval.TotalMilliseconds));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.DurableTask.Entities;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an agent session ID, which is used to identify a long-running agent session.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(AgentSessionIdJsonConverter))]
|
||||
public readonly struct AgentSessionId : IEquatable<AgentSessionId>
|
||||
{
|
||||
private const string EntityNamePrefix = "dafx-";
|
||||
private readonly EntityInstanceId _entityId;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentSessionId"/> struct.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the agent that owns the session (case-insensitive).</param>
|
||||
/// <param name="key">The unique key of the agent session (case-sensitive).</param>
|
||||
public AgentSessionId(string name, string key)
|
||||
{
|
||||
this.Name = name;
|
||||
this._entityId = new EntityInstanceId(ToEntityName(name), key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an agent name to its underlying entity name representation.
|
||||
/// </summary>
|
||||
/// <param name="name">The agent name.</param>
|
||||
/// <returns>The entity name used by Durable Task for this agent.</returns>
|
||||
public static string ToEntityName(string name) => $"{EntityNamePrefix}{name}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the agent that owns the session. Names are case-insensitive.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique key of the agent session. Keys are case-sensitive and are used to identify the session.
|
||||
/// </summary>
|
||||
public string Key => this._entityId.Key;
|
||||
|
||||
internal EntityInstanceId ToEntityId() => this._entityId;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="AgentSessionId"/> with the specified name and a randomly generated key.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the agent that owns the session.</param>
|
||||
/// <returns>A new <see cref="AgentSessionId"/> with the specified name and a random key.</returns>
|
||||
public static AgentSessionId WithRandomKey(string name) =>
|
||||
new(name, Guid.NewGuid().ToString("N"));
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether two <see cref="AgentSessionId"/> instances are equal.
|
||||
/// </summary>
|
||||
/// <param name="left">The first <see cref="AgentSessionId"/> to compare.</param>
|
||||
/// <param name="right">The second <see cref="AgentSessionId"/> to compare.</param>
|
||||
/// <returns><c>true</c> if the two instances are equal; otherwise, <c>false</c>.</returns>
|
||||
public static bool operator ==(AgentSessionId left, AgentSessionId right) =>
|
||||
left._entityId == right._entityId;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether two <see cref="AgentSessionId"/> instances are not equal.
|
||||
/// </summary>
|
||||
/// <param name="left">The first <see cref="AgentSessionId"/> to compare.</param>
|
||||
/// <param name="right">The second <see cref="AgentSessionId"/> to compare.</param>
|
||||
/// <returns><c>true</c> if the two instances are not equal; otherwise, <c>false</c>.</returns>
|
||||
public static bool operator !=(AgentSessionId left, AgentSessionId right) =>
|
||||
left._entityId != right._entityId;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the specified <see cref="AgentSessionId"/> is equal to the current <see cref="AgentSessionId"/>.
|
||||
/// </summary>
|
||||
/// <param name="other">The <see cref="AgentSessionId"/> to compare with the current <see cref="AgentSessionId"/>.</param>
|
||||
/// <returns><c>true</c> if the specified <see cref="AgentSessionId"/> is equal to the current <see cref="AgentSessionId"/>; otherwise, <c>false</c>.</returns>
|
||||
public bool Equals(AgentSessionId other) => this == other;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the specified object is equal to the current <see cref="AgentSessionId"/>.
|
||||
/// </summary>
|
||||
/// <param name="obj">The object to compare with the current <see cref="AgentSessionId"/>.</param>
|
||||
/// <returns><c>true</c> if the specified object is equal to the current <see cref="AgentSessionId"/>; otherwise, <c>false</c>.</returns>
|
||||
public override bool Equals(object? obj) => obj is AgentSessionId other && this == other;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the hash code for this <see cref="AgentSessionId"/>.
|
||||
/// </summary>
|
||||
/// <returns>A hash code for the current <see cref="AgentSessionId"/>.</returns>
|
||||
public override int GetHashCode() => this._entityId.GetHashCode();
|
||||
|
||||
/// <summary>
|
||||
/// Returns a string representation of this <see cref="AgentSessionId"/> in the form of @name@key.
|
||||
/// </summary>
|
||||
/// <returns>A string representation of the current <see cref="AgentSessionId"/>.</returns>
|
||||
public override string ToString() => this._entityId.ToString();
|
||||
|
||||
/// <summary>
|
||||
/// Converts the string representation of an agent session ID to its <see cref="AgentSessionId"/> equivalent.
|
||||
/// The input string must be in the form of @name@key.
|
||||
/// </summary>
|
||||
/// <param name="sessionIdString">A string containing an agent session ID to convert.</param>
|
||||
/// <returns>A <see cref="AgentSessionId"/> equivalent to the agent session ID contained in <paramref name="sessionIdString"/>.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="sessionIdString"/> is not a valid agent session ID format.</exception>
|
||||
public static AgentSessionId Parse(string sessionIdString)
|
||||
{
|
||||
EntityInstanceId entityId = EntityInstanceId.FromString(sessionIdString);
|
||||
if (!entityId.Name.StartsWith(EntityNamePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException($"'{sessionIdString}' is not a valid agent session ID.", nameof(sessionIdString));
|
||||
}
|
||||
|
||||
return new AgentSessionId(entityId.Name[EntityNamePrefix.Length..], entityId.Key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implicitly converts an <see cref="AgentSessionId"/> to an <see cref="EntityInstanceId"/>.
|
||||
/// This conversion is useful for entity API interoperability.
|
||||
/// </summary>
|
||||
/// <param name="agentSessionId">The <see cref="AgentSessionId"/> to convert.</param>
|
||||
/// <returns>The equivalent <see cref="EntityInstanceId"/>.</returns>
|
||||
public static implicit operator EntityInstanceId(AgentSessionId agentSessionId) => agentSessionId.ToEntityId();
|
||||
|
||||
/// <summary>
|
||||
/// Implicitly converts an <see cref="EntityInstanceId"/> to an <see cref="AgentSessionId"/>.
|
||||
/// </summary>
|
||||
/// <param name="entityId">The <see cref="EntityInstanceId"/> to convert.</param>
|
||||
/// <returns>The equivalent <see cref="AgentSessionId"/>.</returns>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "Implicit conversion must validate format.")]
|
||||
public static implicit operator AgentSessionId(EntityInstanceId entityId)
|
||||
{
|
||||
if (!entityId.Name.StartsWith(EntityNamePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException($"'{entityId}' is not a valid agent session ID.", nameof(entityId));
|
||||
}
|
||||
return new AgentSessionId(entityId.Name[EntityNamePrefix.Length..], entityId.Key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom JSON converter for <see cref="AgentSessionId"/> to ensure proper serialization and deserialization.
|
||||
/// </summary>
|
||||
public sealed class AgentSessionIdJsonConverter : JsonConverter<AgentSessionId>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override AgentSessionId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.String)
|
||||
{
|
||||
throw new JsonException("Expected string value");
|
||||
}
|
||||
|
||||
string value = reader.GetString() ?? string.Empty;
|
||||
|
||||
return Parse(value);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Write(Utf8JsonWriter writer, AgentSessionId value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStringValue(value.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Release History
|
||||
|
||||
## v1.0.0-preview.* (Unreleased)
|
||||
|
||||
- Initial public release.
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
internal class DefaultDurableAgentClient(DurableTaskClient client, ILoggerFactory? loggerFactory = null) : IDurableAgentClient
|
||||
{
|
||||
private readonly DurableTaskClient _client = client;
|
||||
private readonly ILogger? _logger = loggerFactory?.CreateLogger<DefaultDurableAgentClient>();
|
||||
|
||||
public async Task<AgentRunHandle> RunAgentAsync(
|
||||
AgentSessionId sessionId,
|
||||
RunRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// The correlation ID is used to fetch the correct response later.
|
||||
request.CorrelationId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// TODO: Use source generators to log the request
|
||||
this._logger?.LogInformation("Signalling agent with session ID '{SessionId}'", sessionId);
|
||||
|
||||
await this._client.Entities.SignalEntityAsync(
|
||||
sessionId,
|
||||
nameof(AgentEntity.RunAgentAsync),
|
||||
request,
|
||||
cancellation: cancellationToken);
|
||||
|
||||
return new AgentRunHandle(this._client, sessionId, request.CorrelationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// A durable AIAgent implementation that uses entity methods to interact with agent entities.
|
||||
/// </summary>
|
||||
public sealed class DurableAIAgent : AIAgent
|
||||
{
|
||||
private readonly TaskOrchestrationContext _context;
|
||||
private readonly string _agentName;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableAIAgent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The orchestration context.</param>
|
||||
/// <param name="agentName">The name of the agent.</param>
|
||||
internal DurableAIAgent(TaskOrchestrationContext context, string agentName)
|
||||
{
|
||||
this._context = context;
|
||||
this._agentName = agentName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new agent thread for this agent using a random session ID.
|
||||
/// </summary>
|
||||
/// <returns>A new agent thread.</returns>
|
||||
public override AgentThread GetNewThread()
|
||||
{
|
||||
AgentSessionId sessionId = this._context.NewAgentSessionId(this._agentName);
|
||||
return new DurableAgentThread(sessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes an agent thread from JSON.
|
||||
/// </summary>
|
||||
/// <param name="serializedThread">The serialized thread data.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional JSON serializer options.</param>
|
||||
/// <returns>The deserialized agent thread.</returns>
|
||||
public override AgentThread DeserializeThread(
|
||||
JsonElement serializedThread,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
return DurableAgentThread.Deserialize(serializedThread, jsonSerializerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with messages and returns the response.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to send to the agent.</param>
|
||||
/// <param name="thread">The agent thread to use.</param>
|
||||
/// <param name="options">Optional run options.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The response from the agent.</returns>
|
||||
public override async Task<AgentRunResponse> RunAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (cancellationToken != default && cancellationToken.CanBeCanceled)
|
||||
{
|
||||
throw new NotSupportedException("Cancellation is not supported for durable agents.");
|
||||
}
|
||||
|
||||
thread ??= this.GetNewThread();
|
||||
if (thread is not DurableAgentThread durableThread)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The provided thread is not valid for a durable agent. " +
|
||||
"Create a new thread using GetNewThread or provide a thread previously created by this agent.",
|
||||
paramName: nameof(thread));
|
||||
}
|
||||
|
||||
IList<string>? enableToolNames = null;
|
||||
bool enableToolCalls = true;
|
||||
ChatResponseFormat? responseFormat = null;
|
||||
if (options is DurableAgentRunOptions durableOptions)
|
||||
{
|
||||
enableToolCalls = durableOptions.EnableToolCalls;
|
||||
enableToolNames = durableOptions.EnableToolNames;
|
||||
responseFormat = durableOptions.ResponseFormat;
|
||||
}
|
||||
else if (options is ChatClientAgentRunOptions chatClientOptions && chatClientOptions.ChatOptions?.Tools != null)
|
||||
{
|
||||
// Honor the response format from the chat client options if specified
|
||||
responseFormat = chatClientOptions.ChatOptions?.ResponseFormat;
|
||||
}
|
||||
|
||||
RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames);
|
||||
return await this._context.Entities.CallEntityAsync<AgentRunResponse>(durableThread.SessionId, nameof(AgentEntity.RunAgentAsync), request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with messages and returns a simulated streaming response.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Streaming is not supported for durable agents, so this method just returns the full response
|
||||
/// as a single update.
|
||||
/// </remarks>
|
||||
/// <param name="messages">The messages to send to the agent.</param>
|
||||
/// <param name="thread">The agent thread to use.</param>
|
||||
/// <param name="options">Optional run options.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A streaming response enumerable.</returns>
|
||||
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Streaming is not supported for durable agents, so we just return the full response
|
||||
// as a single update.
|
||||
AgentRunResponse response = await this.RunAsync(messages, thread, options, cancellationToken);
|
||||
foreach (AgentRunResponseUpdate update in response.ToAgentRunResponseUpdates())
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a message and returns the deserialized output as an instance of <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to send to the agent.</param>
|
||||
/// <param name="thread">The agent thread to use.</param>
|
||||
/// <param name="serializerOptions">Optional JSON serializer options.</param>
|
||||
/// <param name="options">Optional run options.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <typeparam name="T">The type of the output.</typeparam>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when the provided <paramref name="options"/> already contains a response schema.
|
||||
/// Thrown when the provided <paramref name="options"/> is not a <see cref="DurableAgentRunOptions"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when the agent response is empty or cannot be deserialized.
|
||||
/// </exception>
|
||||
/// <returns>The output from the agent.</returns>
|
||||
public async Task<AgentRunResponse<T>> RunAsync<T>(
|
||||
string message,
|
||||
AgentThread? thread = null,
|
||||
JsonSerializerOptions? serializerOptions = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await this.RunAsync<T>([new ChatMessage(ChatRole.User, message)], thread, serializerOptions, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with messages and returns the deserialized output as an instance of <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to send to the agent.</param>
|
||||
/// <param name="thread">The agent thread to use.</param>
|
||||
/// <param name="serializerOptions">Optional JSON serializer options.</param>
|
||||
/// <param name="options">Optional run options.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <typeparam name="T">The type of the output.</typeparam>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when the provided <paramref name="options"/> already contains a response schema.
|
||||
/// Thrown when the provided <paramref name="options"/> is not a <see cref="DurableAgentRunOptions"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when the agent response is empty or cannot be deserialized.
|
||||
/// </exception>
|
||||
/// <returns>The output from the agent.</returns>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback to reflection-based deserialization is intentional for library flexibility with user-defined types.")]
|
||||
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback to reflection-based deserialization is intentional for library flexibility with user-defined types.")]
|
||||
public async Task<AgentRunResponse<T>> RunAsync<T>(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
JsonSerializerOptions? serializerOptions = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= new DurableAgentRunOptions();
|
||||
if (options is not DurableAgentRunOptions durableOptions)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Response schema is only supported with DurableAgentRunOptions when using durable agents. " +
|
||||
"Cannot specify a response schema when calling RunAsync<T>.",
|
||||
paramName: nameof(options));
|
||||
}
|
||||
|
||||
if (durableOptions.ResponseFormat is not null)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"A response schema is already defined in the provided DurableAgentRunOptions. " +
|
||||
"Cannot specify a response schema when calling RunAsync<T>.",
|
||||
paramName: nameof(options));
|
||||
}
|
||||
|
||||
// Create the JSON schema for the response type
|
||||
durableOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema<T>();
|
||||
|
||||
AgentRunResponse response = await this.RunAsync(messages, thread, durableOptions, cancellationToken);
|
||||
|
||||
// Deserialize the response text to the requested type
|
||||
if (string.IsNullOrEmpty(response.Text))
|
||||
{
|
||||
throw new InvalidOperationException("Agent response is empty and cannot be deserialized.");
|
||||
}
|
||||
|
||||
serializerOptions ??= DurableAgentJsonUtilities.DefaultOptions;
|
||||
|
||||
// Prefer source-generated metadata when available to support AOT/trimming scenarios.
|
||||
// Fallback to reflection-based deserialization for types without source-generated metadata.
|
||||
// This is necessary since T is a user-provided type that may not have [JsonSerializable] coverage.
|
||||
JsonTypeInfo? typeInfo = serializerOptions.GetTypeInfo(typeof(T));
|
||||
T? result = (typeInfo is JsonTypeInfo typedInfo
|
||||
? (T?)JsonSerializer.Deserialize(response.Text, typedInfo)
|
||||
: JsonSerializer.Deserialize<T>(response.Text, serializerOptions))
|
||||
?? throw new InvalidOperationException($"Failed to deserialize agent response to type {typeof(T).Name}.");
|
||||
|
||||
return new DurableAIAgentRunResponse<T>(response, result);
|
||||
}
|
||||
|
||||
private sealed class DurableAIAgentRunResponse<T>(AgentRunResponse response, T result)
|
||||
: AgentRunResponse<T>(response.AsChatResponse())
|
||||
{
|
||||
public override T Result { get; } = result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient) : AIAgent
|
||||
{
|
||||
private readonly IDurableAgentClient _agentClient = agentClient;
|
||||
|
||||
public override string? Name { get; } = name;
|
||||
|
||||
public override AgentThread DeserializeThread(
|
||||
JsonElement serializedThread,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
return DurableAgentThread.Deserialize(serializedThread, jsonSerializerOptions);
|
||||
}
|
||||
|
||||
public override AgentThread GetNewThread()
|
||||
{
|
||||
return new DurableAgentThread(AgentSessionId.WithRandomKey(this.Name!));
|
||||
}
|
||||
|
||||
public override async Task<AgentRunResponse> RunAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
thread ??= this.GetNewThread();
|
||||
if (thread is not DurableAgentThread durableThread)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The provided thread is not valid for a durable agent. " +
|
||||
"Create a new thread using GetNewThread or provide a thread previously created by this agent.",
|
||||
paramName: nameof(thread));
|
||||
}
|
||||
|
||||
IList<string>? enableToolNames = null;
|
||||
bool enableToolCalls = true;
|
||||
ChatResponseFormat? responseFormat = null;
|
||||
bool isFireAndForget = false;
|
||||
|
||||
if (options is DurableAgentRunOptions durableOptions)
|
||||
{
|
||||
enableToolCalls = durableOptions.EnableToolCalls;
|
||||
enableToolNames = durableOptions.EnableToolNames;
|
||||
responseFormat = durableOptions.ResponseFormat;
|
||||
isFireAndForget = durableOptions.IsFireAndForget;
|
||||
}
|
||||
else if (options is ChatClientAgentRunOptions chatClientOptions)
|
||||
{
|
||||
// Honor the response format from the chat client options if specified
|
||||
responseFormat = chatClientOptions.ChatOptions?.ResponseFormat;
|
||||
}
|
||||
|
||||
RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames);
|
||||
AgentSessionId sessionId = durableThread.SessionId;
|
||||
|
||||
AgentRunHandle agentRunHandle = await this._agentClient.RunAgentAsync(sessionId, request, cancellationToken);
|
||||
|
||||
if (isFireAndForget)
|
||||
{
|
||||
// If the request is fire and forget, return an empty response.
|
||||
return new AgentRunResponse();
|
||||
}
|
||||
|
||||
return await agentRunHandle.ReadAgentResponseAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException("Streaming is not supported for durable agents.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Entities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// A context for durable agents that provides access to orchestration capabilities.
|
||||
/// This class provides thread-static access to the current agent context.
|
||||
/// </summary>
|
||||
public class DurableAgentContext
|
||||
{
|
||||
private static readonly AsyncLocal<DurableAgentContext?> s_currentContext = new();
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
|
||||
internal DurableAgentContext(
|
||||
TaskEntityContext entityContext,
|
||||
DurableTaskClient client,
|
||||
IHostApplicationLifetime lifetime,
|
||||
IServiceProvider services)
|
||||
{
|
||||
this.EntityContext = entityContext;
|
||||
this.CurrentThread = new DurableAgentThread(entityContext.Id);
|
||||
this.Client = client;
|
||||
this._services = services;
|
||||
this._cancellationToken = lifetime.ApplicationStopping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current durable agent context instance.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown when no agent context is available.</exception>
|
||||
public static DurableAgentContext Current => s_currentContext.Value ??
|
||||
throw new InvalidOperationException("No agent context found!");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the entity context for this agent.
|
||||
/// </summary>
|
||||
public TaskEntityContext EntityContext { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the durable task client for this agent.
|
||||
/// </summary>
|
||||
public DurableTaskClient Client { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current agent thread.
|
||||
/// </summary>
|
||||
public DurableAgentThread CurrentThread { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sets the current durable agent context instance.
|
||||
/// This is called internally by the agent entity during execution.
|
||||
/// </summary>
|
||||
/// <param name="context">The context instance to set.</param>
|
||||
internal static void SetCurrent(DurableAgentContext context)
|
||||
{
|
||||
if (s_currentContext.Value is not null)
|
||||
{
|
||||
throw new InvalidOperationException("A DurableAgentContext has already been set for this AsyncLocal context.");
|
||||
}
|
||||
|
||||
s_currentContext.Value = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the current durable agent context instance.
|
||||
/// This is called internally by the agent entity after execution.
|
||||
/// </summary>
|
||||
internal static void ClearCurrent()
|
||||
{
|
||||
s_currentContext.Value = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schedules a new orchestration instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When run in the context of a durable agent tool, the actual scheduling of the orchestration
|
||||
/// occurs after the completion of the tool call. This allows the durable scheduling of the orchestration
|
||||
/// and the agent state update to be committed atomically in a single transaction.
|
||||
/// </remarks>
|
||||
/// <param name="name">The name of the orchestration to schedule.</param>
|
||||
/// <param name="input">The input to the orchestration.</param>
|
||||
/// <param name="options">The options for the orchestration.</param>
|
||||
/// <returns>The instance ID of the scheduled orchestration.</returns>
|
||||
public string ScheduleNewOrchestration(
|
||||
TaskName name,
|
||||
object? input = null,
|
||||
StartOrchestrationOptions? options = null)
|
||||
{
|
||||
return this.EntityContext.ScheduleNewOrchestration(name, input, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of an orchestration instance.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">The instance ID of the orchestration to get the status of.</param>
|
||||
/// <param name="includeDetails">Whether to include detailed information about the orchestration.</param>
|
||||
/// <returns>The status of the orchestration.</returns>
|
||||
public Task<OrchestrationMetadata?> GetOrchestrationStatusAsync(string instanceId, bool includeDetails = false)
|
||||
{
|
||||
return this.Client.GetInstanceAsync(instanceId, includeDetails, this._cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises an event on an orchestration instance.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">The instance ID of the orchestration to raise the event on.</param>
|
||||
/// <param name="eventName">The name of the event to raise.</param>
|
||||
/// <param name="eventData">The data to send with the event.</param>
|
||||
#pragma warning disable CA1030 // Use events where appropriate
|
||||
public Task RaiseOrchestrationEventAsync(string instanceId, string eventName, object? eventData = null)
|
||||
#pragma warning restore CA1030 // Use events where appropriate
|
||||
{
|
||||
return this.Client.RaiseEventAsync(instanceId, eventName, eventData, this._cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks the <see cref="DurableAgentContext"/> for an object of the specified type, <typeparamref name="TService"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TService">The type of the object being requested.</typeparam>
|
||||
/// <param name="serviceKey">An optional key to identify the service instance.</param>
|
||||
/// <returns>The service instance, or <see langword="null"/> if the service is not found.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when <paramref name="serviceKey"/> is not <see langword="null"/> and the service provider does not support keyed services.
|
||||
/// </exception>
|
||||
public TService? GetService<TService>(object? serviceKey = null)
|
||||
{
|
||||
return this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks the <see cref="DurableAgentContext"/> for an object of the specified type, <paramref name="serviceType"/>.
|
||||
/// </summary>
|
||||
/// <param name="serviceType">The type of the object being requested.</param>
|
||||
/// <param name="serviceKey">An optional key to identify the service instance.</param>
|
||||
/// <returns>The service instance, or <see langword="null"/> if the service is not found.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when <paramref name="serviceKey"/> is not <see langword="null"/> and the service provider does not support keyed services.
|
||||
/// </exception>
|
||||
public object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
if (serviceKey is not null)
|
||||
{
|
||||
if (this._services is not IKeyedServiceProvider keyedServiceProvider)
|
||||
{
|
||||
throw new InvalidOperationException("The service provider does not support keyed services.");
|
||||
}
|
||||
|
||||
return keyedServiceProvider.GetKeyedService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
return this._services.GetService(serviceType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>Provides JSON serialization utilities and source-generated contracts for Durable Agent types.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This mirrors the pattern used by other libraries (e.g. <c>WorkflowsJsonUtilities</c>) to enable Native AOT and trimming
|
||||
/// friendly serialization without relying on runtime reflection. It establishes a singleton <see cref="JsonSerializerOptions"/>
|
||||
/// instance that is preconfigured with:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item><description><see cref="JsonSerializerDefaults.Web"/> baseline defaults.</description></item>
|
||||
/// <item><description><see cref="JsonIgnoreCondition.WhenWritingNull"/> for default null-value suppression.</description></item>
|
||||
/// <item><description><see cref="JsonNumberHandling.AllowReadingFromString"/> to tolerate numbers encoded as strings.</description></item>
|
||||
/// <item><description>Chained type info resolvers from shared agent abstractions to cover cross-package types (e.g. <see cref="ChatMessage"/>, <see cref="AgentRunResponse"/>).</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Keep the list of <c>[JsonSerializable]</c> types in sync with the Durable Agent data model anytime new state or request/response
|
||||
/// containers are introduced that must round-trip via JSON.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static partial class DurableAgentJsonUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the singleton <see cref="JsonSerializerOptions"/> used for Durable Agent serialization.
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a sequence of chat messages using the durable agent default options.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to serialize.</param>
|
||||
/// <returns>A <see cref="JsonElement"/> representing the serialized messages.</returns>
|
||||
public static JsonElement Serialize(this IEnumerable<ChatMessage> messages) =>
|
||||
JsonSerializer.SerializeToElement(messages, DefaultOptions.GetTypeInfo(typeof(IEnumerable<ChatMessage>)));
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes chat messages from a <see cref="JsonElement"/> using durable agent options.
|
||||
/// </summary>
|
||||
/// <param name="element">The JSON element containing the messages.</param>
|
||||
/// <returns>The deserialized list of chat messages.</returns>
|
||||
public static List<ChatMessage> DeserializeMessages(this JsonElement element) =>
|
||||
(List<ChatMessage>?)element.Deserialize(DefaultOptions.GetTypeInfo(typeof(List<ChatMessage>))) ?? [];
|
||||
|
||||
/// <summary>
|
||||
/// Creates the configured <see cref="JsonSerializerOptions"/> instance for durable agents.
|
||||
/// </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()
|
||||
{
|
||||
// Base configuration from the source-generated context below.
|
||||
JsonSerializerOptions options = new(JsonContext.Default.Options);
|
||||
|
||||
// Chain in shared abstractions resolver (Microsoft.Extensions.AI + Agent abstractions) so dependent types are covered.
|
||||
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
|
||||
|
||||
options.MakeReadOnly();
|
||||
return options;
|
||||
}
|
||||
|
||||
// Keep in sync with CreateDefaultOptions above.
|
||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
|
||||
|
||||
// Durable Agent State Types
|
||||
[JsonSerializable(typeof(DurableAgentState))]
|
||||
[JsonSerializable(typeof(AgentStateEntry))]
|
||||
[JsonSerializable(typeof(DurableAgentThread))]
|
||||
|
||||
// Request Types
|
||||
[JsonSerializable(typeof(RunRequest))]
|
||||
|
||||
// Primitive / Supporting Types
|
||||
[JsonSerializable(typeof(ChatMessage))]
|
||||
[JsonSerializable(typeof(JsonElement))]
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal sealed partial class JsonContext : JsonSerializerContext;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Options for running a durable agent.
|
||||
/// </summary>
|
||||
public sealed class DurableAgentRunOptions : AgentRunOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether to enable tool calls for this request.
|
||||
/// </summary>
|
||||
public bool EnableToolCalls { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the collection of tool names to enable. If not specified, all tools are enabled.
|
||||
/// </summary>
|
||||
public IList<string>? EnableToolNames { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the response format for the agent's response.
|
||||
/// </summary>
|
||||
public ChatResponseFormat? ResponseFormat { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to fire and forget the agent run request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If <see cref="IsFireAndForget"/> is <c>true</c>, the agent run request will be sent and the method will return immediately.
|
||||
/// The caller will not wait for the agent to complete the run and will not receive a response. This setting is useful for
|
||||
/// long-running tasks where the caller does not need to wait for the agent to complete the run.
|
||||
/// </remarks>
|
||||
public bool IsFireAndForget { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a durable agent, including its conversation history.
|
||||
/// </summary>
|
||||
public class DurableAgentState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the ordered list of state entries representing the complete conversation history.
|
||||
/// This includes both user messages and agent responses in chronological order.
|
||||
/// </summary>
|
||||
public List<AgentStateEntry> ConversationHistory { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all chat messages from the conversation history.
|
||||
/// </summary>
|
||||
/// <returns>A collection of chat messages in chronological order.</returns>
|
||||
public IEnumerable<ChatMessage> EnumerateChatMessages()
|
||||
{
|
||||
foreach (AgentStateEntry entry in this.ConversationHistory)
|
||||
{
|
||||
if (entry.Type == AgentStateEntry.EntryType.ChatMessage)
|
||||
{
|
||||
yield return entry.ChatMessage!;
|
||||
}
|
||||
else if (entry.Type == AgentStateEntry.EntryType.AgentResponse)
|
||||
{
|
||||
foreach (ChatMessage message in entry.AgentResponse!.Messages)
|
||||
{
|
||||
yield return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an agent response from the conversation history.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID of the agent response to get.</param>
|
||||
/// <param name="response">The agent response if found, null otherwise.</param>
|
||||
/// <returns>True if the agent response was found, false otherwise.</returns>
|
||||
public bool TryGetAgentResponse(string correlationId, [NotNullWhen(true)] out AgentRunResponse? response)
|
||||
{
|
||||
foreach (AgentStateEntry entry in this.ConversationHistory.Where(
|
||||
entry => entry.Type == AgentStateEntry.EntryType.AgentResponse &&
|
||||
entry.CorrelationId == correlationId))
|
||||
{
|
||||
response = entry.AgentResponse!;
|
||||
return true;
|
||||
}
|
||||
|
||||
response = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a chat message to the state.
|
||||
/// </summary>
|
||||
/// <param name="message">The chat message to add.</param>
|
||||
public void AddChatMessage(ChatMessage message)
|
||||
{
|
||||
this.ConversationHistory.Add(AgentStateEntry.CreateChatMessage(message));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an agent response to the state.
|
||||
/// </summary>
|
||||
/// <param name="response">The agent response to add.</param>
|
||||
/// <param name="correlationId">The correlation ID for the agent response.</param>
|
||||
public void AddAgentResponse(AgentRunResponse response, string? correlationId)
|
||||
{
|
||||
this.ConversationHistory.Add(AgentStateEntry.CreateAgentResponse(response, correlationId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single entry in the durable agent state, which can either be a chat message or agent response.
|
||||
/// This maintains chronological order of all interactions while preserving strong typing.
|
||||
/// </summary>
|
||||
public sealed class AgentStateEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentStateEntry"/> class.
|
||||
/// </summary>
|
||||
/// <param name="chatMessage">The chat message, if this entry represents a chat message.</param>
|
||||
/// <param name="agentResponse">The agent response, if this entry represents an agent response.</param>
|
||||
/// <param name="correlationId">The correlation ID associated with the agent response, if any.</param>
|
||||
[JsonConstructor]
|
||||
public AgentStateEntry(ChatMessage? chatMessage, AgentRunResponse? agentResponse, string? correlationId)
|
||||
{
|
||||
this.ChatMessage = chatMessage;
|
||||
this.AgentResponse = agentResponse;
|
||||
this.Type = agentResponse is null ? EntryType.ChatMessage : EntryType.AgentResponse;
|
||||
this.CorrelationId = correlationId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of this state entry.
|
||||
/// </summary>
|
||||
public EntryType Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the correlation ID for this entry.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chat message, if this entry is a chat message.
|
||||
/// </summary>
|
||||
public ChatMessage? ChatMessage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent response, if this entry is an agent response.
|
||||
/// </summary>
|
||||
public AgentRunResponse? AgentResponse { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a chat message entry.
|
||||
/// </summary>
|
||||
/// <param name="message">The chat message.</param>
|
||||
/// <returns>A new chat message entry.</returns>
|
||||
public static AgentStateEntry CreateChatMessage(ChatMessage message) => new(message, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an agent response entry.
|
||||
/// </summary>
|
||||
/// <param name="response">The agent response.</param>
|
||||
/// <param name="correlationId">The correlation ID for the agent response.</param>
|
||||
/// <returns>A new agent response entry.</returns>
|
||||
public static AgentStateEntry CreateAgentResponse(AgentRunResponse response, string? correlationId) => new(null, response, correlationId);
|
||||
|
||||
/// <summary>
|
||||
/// Defines the types of entries that can be stored in the durable agent state.
|
||||
/// </summary>
|
||||
public enum EntryType
|
||||
{
|
||||
/// <summary>
|
||||
/// A user chat message.
|
||||
/// </summary>
|
||||
ChatMessage,
|
||||
|
||||
/// <summary>
|
||||
/// An agent response.
|
||||
/// </summary>
|
||||
AgentResponse
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// An agent thread implementation for durable agents.
|
||||
/// </summary>
|
||||
[DebuggerDisplay("{SessionId}")]
|
||||
public sealed class DurableAgentThread : AgentThread
|
||||
{
|
||||
[JsonConstructor]
|
||||
internal DurableAgentThread(AgentSessionId sessionId)
|
||||
{
|
||||
this.SessionId = sessionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent session ID.
|
||||
/// </summary>
|
||||
[JsonInclude]
|
||||
[JsonPropertyName("sessionId")]
|
||||
internal AgentSessionId SessionId { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
return JsonSerializer.SerializeToElement(
|
||||
this,
|
||||
DurableAgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(DurableAgentThread)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a DurableAgentThread from JSON.
|
||||
/// </summary>
|
||||
/// <param name="serializedThread">The serialized thread data.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional JSON serializer options.</param>
|
||||
/// <returns>The deserialized DurableAgentThread.</returns>
|
||||
public static DurableAgentThread Deserialize(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
if (!serializedThread.TryGetProperty("sessionId", out JsonElement sessionIdElement) ||
|
||||
sessionIdElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new JsonException("Invalid or missing sessionId property.");
|
||||
}
|
||||
|
||||
string sessionIdString = sessionIdElement.GetString() ?? throw new JsonException("sessionId property is null.");
|
||||
AgentSessionId sessionId = AgentSessionId.Parse(sessionIdString);
|
||||
return new DurableAgentThread(sessionId);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
// This is a common convention for MAF agents.
|
||||
if (serviceType == typeof(AgentThreadMetadata))
|
||||
{
|
||||
return new AgentThreadMetadata(conversationId: this.SessionId.ToString());
|
||||
}
|
||||
|
||||
if (serviceType == typeof(AgentSessionId))
|
||||
{
|
||||
return this.SessionId;
|
||||
}
|
||||
|
||||
return base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString()
|
||||
{
|
||||
return this.SessionId.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Builder for configuring durable agents.
|
||||
/// </summary>
|
||||
public sealed class DurableAgentsOptions
|
||||
{
|
||||
// Agent names are case-insensitive
|
||||
private readonly Dictionary<string, Func<IServiceProvider, AIAgent>> _agentFactories = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
internal DurableAgentsOptions()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an AI agent factory to the options.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="factory">The factory function to create the agent.</param>
|
||||
/// <returns>The options instance.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> or <paramref name="factory"/> is null.</exception>
|
||||
public DurableAgentsOptions AddAIAgentFactory(string name, Func<IServiceProvider, AIAgent> factory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
this._agentFactories.Add(name, factory);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a list of AI agents to the options.
|
||||
/// </summary>
|
||||
/// <param name="agents">The list of agents to add.</param>
|
||||
/// <returns>The options instance.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="agents"/> is null.</exception>
|
||||
public DurableAgentsOptions AddAIAgents(params IEnumerable<AIAgent> agents)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agents);
|
||||
foreach (AIAgent agent in agents)
|
||||
{
|
||||
this.AddAIAgent(agent);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an AI agent to the options.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent to add.</param>
|
||||
/// <returns>The options instance.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="agent"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when <paramref name="agent.Name"/> is null or whitespace or when an agent with the same name has already been registered.
|
||||
/// </exception>
|
||||
public DurableAgentsOptions AddAIAgent(AIAgent agent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(agent.Name))
|
||||
{
|
||||
throw new ArgumentException($"{nameof(agent.Name)} must not be null or whitespace.", nameof(agent));
|
||||
}
|
||||
|
||||
if (this._agentFactories.ContainsKey(agent.Name))
|
||||
{
|
||||
throw new ArgumentException($"An agent with name '{agent.Name}' has already been registered.", nameof(agent));
|
||||
}
|
||||
|
||||
this._agentFactories.Add(agent.Name, sp => agent);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agents that have been added to this builder.
|
||||
/// </summary>
|
||||
/// <returns>A read-only collection of agents.</returns>
|
||||
internal IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> GetAgentFactories()
|
||||
{
|
||||
return this._agentFactories.AsReadOnly();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.DurableTask.Entities;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
internal sealed class EntityAgentWrapper(
|
||||
AIAgent innerAgent,
|
||||
TaskEntityContext entityContext,
|
||||
RunRequest runRequest,
|
||||
IServiceProvider? entityScopedServices = null) : DelegatingAIAgent(innerAgent)
|
||||
{
|
||||
private readonly TaskEntityContext _entityContext = entityContext;
|
||||
private readonly RunRequest _runRequest = runRequest;
|
||||
private readonly IServiceProvider? _entityScopedServices = entityScopedServices;
|
||||
|
||||
// The ID of the agent is always the entity ID.
|
||||
public override string Id => this._entityContext.Id.ToString();
|
||||
|
||||
public override async Task<AgentRunResponse> RunAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
AgentRunResponse response = await base.RunAsync(
|
||||
messages,
|
||||
thread,
|
||||
this.GetAgentEntityRunOptions(options),
|
||||
cancellationToken);
|
||||
|
||||
response.AgentId = this.Id;
|
||||
return response;
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await foreach (AgentRunResponseUpdate update in base.RunStreamingAsync(
|
||||
messages,
|
||||
thread,
|
||||
this.GetAgentEntityRunOptions(options),
|
||||
cancellationToken))
|
||||
{
|
||||
update.AgentId = this.Id;
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
// Override the GetService method to provide entity-scoped services.
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
object? result = null;
|
||||
if (this._entityScopedServices is not null)
|
||||
{
|
||||
result = (serviceKey is not null && this._entityScopedServices is IKeyedServiceProvider keyedServiceProvider)
|
||||
? keyedServiceProvider.GetKeyedService(serviceType, serviceKey)
|
||||
: this._entityScopedServices.GetService(serviceType);
|
||||
}
|
||||
|
||||
return result ?? base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
private AgentRunOptions GetAgentEntityRunOptions(AgentRunOptions? options = null)
|
||||
{
|
||||
// Copied/modified from FunctionInvocationDelegatingAgent.cs in microsoft/agent-framework.
|
||||
if (options is null || options.GetType() == typeof(AgentRunOptions))
|
||||
{
|
||||
options = new ChatClientAgentRunOptions();
|
||||
}
|
||||
|
||||
if (options is not ChatClientAgentRunOptions chatAgentRunOptions)
|
||||
{
|
||||
throw new NotSupportedException($"Function Invocation Middleware is only supported without options or with {nameof(ChatClientAgentRunOptions)}.");
|
||||
}
|
||||
|
||||
Func<IChatClient, IChatClient>? originalFactory = chatAgentRunOptions.ChatClientFactory;
|
||||
|
||||
chatAgentRunOptions.ChatClientFactory = chatClient =>
|
||||
{
|
||||
ChatClientBuilder builder = chatClient.AsBuilder();
|
||||
if (originalFactory is not null)
|
||||
{
|
||||
builder.Use(originalFactory);
|
||||
}
|
||||
|
||||
// Update the run options based on the run request.
|
||||
// NOTE: Function middleware can go here if needed in the future.
|
||||
return builder.ConfigureOptions(
|
||||
newOptions =>
|
||||
{
|
||||
// Update the response format if requested by the caller.
|
||||
if (this._runRequest.ResponseFormat is not null)
|
||||
{
|
||||
newOptions.ResponseFormat = this._runRequest.ResponseFormat;
|
||||
}
|
||||
|
||||
// Update the tools if requested by the caller.
|
||||
if (this._runRequest.EnableToolCalls)
|
||||
{
|
||||
IList<AITool>? tools = chatAgentRunOptions.ChatOptions?.Tools;
|
||||
if (tools is not null && this._runRequest.EnableToolNames?.Count > 0)
|
||||
{
|
||||
// Filter tools to only include those with matching names
|
||||
newOptions.Tools = [.. tools.Where(tool => this._runRequest.EnableToolNames.Contains(tool.Name))];
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
newOptions.Tools = null;
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
};
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for processing responses from the agent. This is typically used to send messages to the user.
|
||||
/// </summary>
|
||||
public interface IAgentResponseHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles a streaming response update from the agent. This is typically used to send messages to the user.
|
||||
/// </summary>
|
||||
/// <param name="messageStream">
|
||||
/// The stream of messages from the agent.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">
|
||||
/// Signals that the operation should be cancelled.
|
||||
/// </param>
|
||||
ValueTask OnStreamingResponseUpdateAsync(
|
||||
IAsyncEnumerable<AgentRunResponseUpdate> messageStream,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Handles a discrete response from the agent. This is typically used to send messages to the user.
|
||||
/// </summary>
|
||||
/// <param name="message">
|
||||
/// The message from the agent.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">
|
||||
/// Signals that the operation should be cancelled.
|
||||
/// </param>
|
||||
ValueTask OnAgentResponseAsync(
|
||||
AgentRunResponse message,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a client for interacting with a durable agent.
|
||||
/// </summary>
|
||||
internal interface IDurableAgentClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs an agent with the specified request.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The ID of the target agent session.</param>
|
||||
/// <param name="request">The request containing the message, role, and configuration.</param>
|
||||
/// <param name="cancellationToken">The cancellation token for scheduling the request.</param>
|
||||
/// <returns>A task that returns a handle used to read the agent response.</returns>
|
||||
Task<AgentRunHandle> RunAgentAsync(
|
||||
AgentSessionId sessionId,
|
||||
RunRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
internal static partial class Logs
|
||||
{
|
||||
[LoggerMessage(
|
||||
EventId = 1,
|
||||
Level = LogLevel.Information,
|
||||
Message = "[{SessionId}] Request: [{Role}] {Content}")]
|
||||
public static partial void LogAgentRequest(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId,
|
||||
ChatRole role,
|
||||
string content);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2,
|
||||
Level = LogLevel.Information,
|
||||
Message = "[{SessionId}] Response: [{Role}] {Content} (Input tokens: {InputTokenCount}, Output tokens: {OutputTokenCount}, Total tokens: {TotalTokenCount})")]
|
||||
public static partial void LogAgentResponse(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId,
|
||||
ChatRole role,
|
||||
string content,
|
||||
long? inputTokenCount,
|
||||
long? outputTokenCount,
|
||||
long? totalTokenCount);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<!-- CA2007: This rule should generally be suppressed in Durable Task libraries -->
|
||||
<!-- MEAI001: UserInputRequestContent is experimental but used in source-generated code for AgentRunResponse -->
|
||||
<NoWarn>$(NoWarn);CA2007;MEAI001</NoWarn>
|
||||
<!-- Disable packing until we are ready to release this as a nuget -->
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<!-- NuGet package metadata -->
|
||||
<PropertyGroup>
|
||||
<Title>Durable Task extensions for Microsoft Agent Framework</Title>
|
||||
<Description>Provides distributed durable execution capabilities for agents built with Microsoft Agent Framework.</Description>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Durable Task dependencies -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.DurableTask.Client" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.DurableTask.UnitTests" />
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="/" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,42 @@
|
||||
# Microsoft.Agents.AI.DurableTask
|
||||
|
||||
The Microsoft Agent Framework provides a programming model for building agents and agent workflows in .NET. This package, the *Durable extensions for the Agent Framework*, extends the Agent Framework programming model with the following capabilities:
|
||||
|
||||
- Stateful, durable execution of agents in distributed environments
|
||||
- Automatic conversation history management
|
||||
- Long-running agent workflows as "durable orchestrator" functions
|
||||
- Tools and dashboards for managing and monitoring agents and agent workflows
|
||||
|
||||
These capabilities are implemented using foundational technologies from the Durable Task technology stack:
|
||||
|
||||
- [Durable Entities](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-entities) for stateful, durable execution of agents
|
||||
- [Durable Orchestrations](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-orchestrations) for long-running agent workflows
|
||||
- The [Durable Task Scheduler](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/choose-orchestration-framework) for managing durable task execution and observability at scale
|
||||
|
||||
This package can be used by itself or in conjunction with the [Microsoft.Agents.AI.Hosting.AzureFunctions](https://www.nuget.org/packages/Microsoft.Agents.AI.Hosting.AzureFunctions) package, which provides additional features via Azure Functions integration.
|
||||
|
||||
## Install the package
|
||||
|
||||
From the command-line:
|
||||
|
||||
```bash
|
||||
dotnet add package Microsoft.Agents.AI.DurableTask
|
||||
```
|
||||
|
||||
Or directly in your project file:
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.DurableTask" Version="[CURRENTVERSION]" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
You can alternatively just reference the [Microsoft.Agents.AI.Hosting.AzureFunctions](https://www.nuget.org/packages/Microsoft.Agents.AI.Hosting.AzureFunctions) package if you're hosting your agents and orchestrations in the Azure Functions .NET Isolated worker.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
For a comprehensive tour of all the functionality, concepts, and APIs, check out the [Azure Functions samples](https://github.com/microsoft/agent-framework/tree/main/dotnet/samples/AzureFunctions).
|
||||
|
||||
## Feedback & Contributing
|
||||
|
||||
We welcome feedback and contributions in [our GitHub repo](https://github.com/microsoft/agent-framework).
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request to run an agent with a specific message and configuration.
|
||||
/// </summary>
|
||||
public record RunRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the list of chat messages to send to the agent (for multi-message requests).
|
||||
/// </summary>
|
||||
public IList<ChatMessage> Messages { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional response format for the agent's response.
|
||||
/// </summary>
|
||||
public ChatResponseFormat? ResponseFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to enable tool calls for this request.
|
||||
/// </summary>
|
||||
public bool EnableToolCalls { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of tool names to enable. If not specified, all tools are enabled.
|
||||
/// </summary>
|
||||
public IList<string>? EnableToolNames { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the correlation ID for correlating this request with its response.
|
||||
/// </summary>
|
||||
[JsonInclude]
|
||||
internal string? CorrelationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RunRequest"/> class for a single message.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to send to the agent.</param>
|
||||
/// <param name="role">The role of the message sender (User or System).</param>
|
||||
/// <param name="responseFormat">Optional response format for the agent's response.</param>
|
||||
/// <param name="enableToolCalls">Whether to enable tool calls for this request.</param>
|
||||
/// <param name="enableToolNames">Optional collection of tool names to enable. If not specified, all tools are enabled.</param>
|
||||
public RunRequest(
|
||||
string message,
|
||||
ChatRole? role = null,
|
||||
ChatResponseFormat? responseFormat = null,
|
||||
bool enableToolCalls = true,
|
||||
IList<string>? enableToolNames = null)
|
||||
: this([new ChatMessage(role ?? ChatRole.User, message)], responseFormat, enableToolCalls, enableToolNames)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RunRequest"/> class for multiple messages.
|
||||
/// </summary>
|
||||
/// <param name="messages">The list of chat messages to send to the agent.</param>
|
||||
/// <param name="responseFormat">Optional response format for the agent's response.</param>
|
||||
/// <param name="enableToolCalls">Whether to enable tool calls for this request.</param>
|
||||
/// <param name="enableToolNames">Optional collection of tool names to enable. If not specified, all tools are enabled.</param>
|
||||
[JsonConstructor]
|
||||
public RunRequest(
|
||||
IList<ChatMessage> messages,
|
||||
ChatResponseFormat? responseFormat = null,
|
||||
bool enableToolCalls = true,
|
||||
IList<string>? enableToolNames = null)
|
||||
{
|
||||
this.Messages = messages;
|
||||
this.ResponseFormat = responseFormat;
|
||||
this.EnableToolCalls = enableToolCalls;
|
||||
this.EnableToolNames = enableToolNames;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Worker;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Agent-specific extension methods for the <see cref="IServiceCollection"/> class.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a durable agent proxy by name.
|
||||
/// </summary>
|
||||
/// <param name="services">The service provider.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <returns>The durable agent proxy.</returns>
|
||||
/// <exception cref="KeyNotFoundException">Thrown if the agent proxy is not found.</exception>
|
||||
public static AIAgent GetDurableAgentProxy(this IServiceProvider services, string name)
|
||||
{
|
||||
return services.GetKeyedService<AIAgent>(name)
|
||||
?? throw new KeyNotFoundException($"A durable agent with name '{name}' has not been registered.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the Durable Agents services via the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">A delegate to configure the durable agents.</param>
|
||||
/// <param name="workerBuilder">A delegate to configure the Durable Task worker.</param>
|
||||
/// <param name="clientBuilder">A delegate to configure the Durable Task client.</param>
|
||||
/// <returns>The service collection.</returns>
|
||||
public static IServiceCollection ConfigureDurableAgents(
|
||||
this IServiceCollection services,
|
||||
Action<DurableAgentsOptions> configure,
|
||||
Action<IDurableTaskWorkerBuilder>? workerBuilder = null,
|
||||
Action<IDurableTaskClientBuilder>? clientBuilder = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
DurableAgentsOptions options = services.ConfigureDurableAgents(configure);
|
||||
|
||||
// A worker is required to run the agent entities
|
||||
services.AddDurableTaskWorker(builder =>
|
||||
{
|
||||
workerBuilder?.Invoke(builder);
|
||||
|
||||
builder.AddTasks(registry =>
|
||||
{
|
||||
foreach (string name in options.GetAgentFactories().Keys)
|
||||
{
|
||||
registry.AddEntity<AgentEntity>(AgentSessionId.ToEntityName(name));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The client is needed to send notifications to the agent entities from non-orchestrator code
|
||||
if (clientBuilder != null)
|
||||
{
|
||||
services.AddDurableTaskClient(clientBuilder);
|
||||
}
|
||||
|
||||
services.AddSingleton<IDurableAgentClient, DefaultDurableAgentClient>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
// This is internal because it's also used by Microsoft.Azure.Functions.DurableAgents, which is a friend assembly project.
|
||||
internal static DurableAgentsOptions ConfigureDurableAgents(
|
||||
this IServiceCollection services,
|
||||
Action<DurableAgentsOptions> configure)
|
||||
{
|
||||
DurableAgentsOptions options = new();
|
||||
configure(options);
|
||||
|
||||
var agents = options.GetAgentFactories();
|
||||
|
||||
// The agent dictionary contains the real agent factories, which is used by the agent entities.
|
||||
services.AddSingleton(agents);
|
||||
|
||||
// The keyed services are used to resolve durable agent *proxy* instances for external clients.
|
||||
foreach (var factory in agents)
|
||||
{
|
||||
services.AddKeyedSingleton(factory.Key, (sp, _) => factory.Value(sp).AsDurableAgentProxy(sp));
|
||||
}
|
||||
|
||||
// A custom data converter is needed because the default chat client uses camel case for JSON properties,
|
||||
// which is not the default behavior for the Durable Task SDK.
|
||||
services.AddSingleton<DataConverter, DefaultDataConverter>();
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private class DefaultDataConverter : DataConverter
|
||||
{
|
||||
// Use durable agent options (web defaults + camel case by default) with case-insensitive matching.
|
||||
// We clone to apply naming/casing tweaks while retaining source-generated metadata where available.
|
||||
private static readonly JsonSerializerOptions s_options = new(DurableAgentJsonUtilities.DefaultOptions)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback path uses reflection when metadata unavailable.")]
|
||||
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback path uses reflection when metadata unavailable.")]
|
||||
public override object? Deserialize(string? data, Type targetType)
|
||||
{
|
||||
if (data is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(targetType);
|
||||
if (typeInfo is JsonTypeInfo typedInfo)
|
||||
{
|
||||
return JsonSerializer.Deserialize(data, typedInfo);
|
||||
}
|
||||
|
||||
// Fallback (may trigger trimming/AOT warnings for unsupported dynamic types).
|
||||
return JsonSerializer.Deserialize(data, targetType, s_options);
|
||||
}
|
||||
|
||||
[return: NotNullIfNotNull(nameof(value))]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback path uses reflection when metadata unavailable.")]
|
||||
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback path uses reflection when metadata unavailable.")]
|
||||
public override string? Serialize(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(value.GetType());
|
||||
if (typeInfo is JsonTypeInfo typedInfo)
|
||||
{
|
||||
return JsonSerializer.Serialize(value, typedInfo);
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(value, s_options);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Microsoft.DurableTask;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Agent-related extension methods for the <see cref="TaskOrchestrationContext"/> class.
|
||||
/// </summary>
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public static class TaskOrchestrationContextExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a <see cref="DurableAIAgent"/> for interacting with hosted agents within an orchestration.
|
||||
/// </summary>
|
||||
/// <param name="context">The orchestration context.</param>
|
||||
/// <param name="agentName">The name of the agent.</param>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="agentName"/> is null or empty.</exception>
|
||||
/// <returns>A <see cref="DurableAIAgent"/> that can be used to interact with the agent.</returns>
|
||||
public static DurableAIAgent GetAgent(
|
||||
this TaskOrchestrationContext context,
|
||||
string agentName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(agentName);
|
||||
return new DurableAIAgent(context, agentName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates an <see cref="AgentSessionId"/> for an agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method is deterministic and safe for use in an orchestration context.
|
||||
/// </remarks>
|
||||
/// <param name="context">The orchestration context.</param>
|
||||
/// <param name="agentName">The name of the agent.</param>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="agentName"/> is null or empty.</exception>
|
||||
/// <returns>The generated agent session ID.</returns>
|
||||
internal static AgentSessionId NewAgentSessionId(
|
||||
this TaskOrchestrationContext context,
|
||||
string agentName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(agentName);
|
||||
|
||||
return new AgentSessionId(agentName, context.NewGuid().ToString("N"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Context.Features;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Azure.Functions.Worker.Invocation;
|
||||
using Microsoft.DurableTask.Client;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// This implementation of function executor handles invocations using the built-in static methods for agent HTTP and entity functions.
|
||||
/// </summary>
|
||||
/// <remarks>By default, the Azure Functions worker generates function executor and that executor is used for function invocations.
|
||||
/// But for the dummy HTTP function we create for agents (by augmenting the metadata), that executor will not have the code to handle that function since the entrypoint is a built-in static method.
|
||||
/// </remarks>
|
||||
internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
||||
{
|
||||
public async ValueTask ExecuteAsync(FunctionContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
// Acquire the input binding feature (fail fast if missing rather than null-forgiving operator).
|
||||
IFunctionInputBindingFeature? functionInputBindingFeature = context.Features.Get<IFunctionInputBindingFeature>();
|
||||
if (functionInputBindingFeature == null)
|
||||
{
|
||||
throw new InvalidOperationException("Function input binding feature is not available on the current context.");
|
||||
}
|
||||
|
||||
FunctionInputBindingResult? inputBindingResults = await functionInputBindingFeature.BindFunctionInputAsync(context);
|
||||
if (inputBindingResults is not { Values: { } values })
|
||||
{
|
||||
throw new InvalidOperationException($"Function input binding failed for the invocation {context.InvocationId}");
|
||||
}
|
||||
|
||||
HttpRequestData? httpRequestData = null;
|
||||
TaskEntityDispatcher? dispatcher = null;
|
||||
DurableTaskClient? durableTaskClient = null;
|
||||
|
||||
foreach (var binding in values)
|
||||
{
|
||||
switch (binding)
|
||||
{
|
||||
case HttpRequestData request:
|
||||
httpRequestData = request;
|
||||
break;
|
||||
case TaskEntityDispatcher entityDispatcher:
|
||||
dispatcher = entityDispatcher;
|
||||
break;
|
||||
case DurableTaskClient client:
|
||||
durableTaskClient = client;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool isAgentHttpInvocation = string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal);
|
||||
|
||||
if (isAgentHttpInvocation)
|
||||
{
|
||||
if (httpRequestData == null)
|
||||
{
|
||||
throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
if (durableTaskClient == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Durable Task client binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
context.GetInvocationResult().Value = await BuiltInFunctions.RunAgentHttpAsync(
|
||||
httpRequestData,
|
||||
durableTaskClient,
|
||||
context);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not HTTP invocation, It will be entity invocation path.
|
||||
if (dispatcher == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Task entity dispatcher binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
if (durableTaskClient == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Durable Task client binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
await BuiltInFunctions.InvokeAgentAsync(
|
||||
dispatcher,
|
||||
durableTaskClient,
|
||||
context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Net;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
internal static class BuiltInFunctions
|
||||
{
|
||||
internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}";
|
||||
internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}";
|
||||
|
||||
// Exposed as an entity trigger via AgentFunctionsProvider
|
||||
public static async Task InvokeAgentAsync(
|
||||
[EntityTrigger] TaskEntityDispatcher dispatcher,
|
||||
[DurableClient] DurableTaskClient client,
|
||||
FunctionContext functionContext)
|
||||
{
|
||||
// This should never be null except if the function trigger is misconfigured.
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(functionContext);
|
||||
|
||||
// Create a combined service provider that includes both the existing services
|
||||
// and the DurableTaskClient instance
|
||||
IServiceProvider combinedServiceProvider = new CombinedServiceProvider(functionContext.InstanceServices, client);
|
||||
|
||||
// This method is the entry point for the agent entity.
|
||||
// It will be invoked by the Azure Functions runtime when the entity is called.
|
||||
await dispatcher.DispatchAsync(new AgentEntity(combinedServiceProvider, functionContext.CancellationToken));
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseData> RunAgentHttpAsync(
|
||||
[HttpTrigger] HttpRequestData req,
|
||||
[DurableClient] DurableTaskClient client,
|
||||
FunctionContext context)
|
||||
{
|
||||
// The session ID is an optional query string parameter.
|
||||
string? threadIdFromQuery = req.Query["threadId"];
|
||||
|
||||
// If no session ID is provided, use a new one based on the function name and invocation ID.
|
||||
// This may be better than a random one because it can be correlated with the function invocation.
|
||||
// Specifying a session ID is how the caller correlates multiple calls to the same agent session.
|
||||
AgentSessionId sessionId = string.IsNullOrEmpty(threadIdFromQuery)
|
||||
? new AgentSessionId(GetAgentName(context), context.InvocationId)
|
||||
: AgentSessionId.Parse(threadIdFromQuery);
|
||||
|
||||
string? message = await req.ReadAsStringAsync();
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await response.WriteAsJsonAsync(
|
||||
new { error = "Run request cannot be empty." },
|
||||
context.CancellationToken);
|
||||
return response;
|
||||
}
|
||||
|
||||
AIAgent agentProxy = client.AsDurableAgentProxy(context, GetAgentName(context));
|
||||
|
||||
AgentRunResponse agentResponse = await agentProxy.RunAsync(
|
||||
message: new ChatMessage(ChatRole.User, message),
|
||||
thread: new DurableAgentThread(sessionId),
|
||||
options: null,
|
||||
cancellationToken: context.CancellationToken);
|
||||
|
||||
HttpResponseData httpResponse = req.CreateResponse(HttpStatusCode.OK);
|
||||
httpResponse.Headers.Add("X-Agent-Thread", sessionId.ToString());
|
||||
|
||||
// If the caller accepts JSON, return the entire response object as JSON.
|
||||
// TODO: Need to define a standard response schema for agent responses.
|
||||
// https://github.com/Azure/durable-agent-framework/issues/113
|
||||
if (req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
|
||||
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
await httpResponse.WriteAsJsonAsync(
|
||||
new { response = agentResponse, threadId = sessionId },
|
||||
context.CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// The default is to return the response as text/plain.
|
||||
httpResponse.Headers.Add("Content-Type", "text/plain");
|
||||
await httpResponse.WriteStringAsync(agentResponse.Text, context.CancellationToken);
|
||||
}
|
||||
|
||||
return httpResponse;
|
||||
}
|
||||
|
||||
private static string GetAgentName(FunctionContext context)
|
||||
{
|
||||
// Remove the trailing _http from the function name
|
||||
string functionName = context.FunctionDefinition.Name;
|
||||
if (!functionName.EndsWith("_http", StringComparison.Ordinal))
|
||||
{
|
||||
// This should never happen because the function metadata provider ensures
|
||||
// that the function name ends with '_http'.
|
||||
throw new InvalidOperationException(
|
||||
$"Built-in HTTP trigger function name '{functionName}' does not end with '_http'.");
|
||||
}
|
||||
|
||||
return functionName[..^5];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A service provider that combines the original service provider with an additional DurableTaskClient instance.
|
||||
/// </summary>
|
||||
private sealed class CombinedServiceProvider(IServiceProvider originalProvider, DurableTaskClient client)
|
||||
: IServiceProvider, IKeyedServiceProvider
|
||||
{
|
||||
private readonly IServiceProvider _originalProvider = originalProvider;
|
||||
private readonly DurableTaskClient _client = client;
|
||||
|
||||
public object? GetKeyedService(Type serviceType, object? serviceKey)
|
||||
{
|
||||
if (this._originalProvider is IKeyedServiceProvider keyedProvider)
|
||||
{
|
||||
return keyedProvider.GetKeyedService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public object GetRequiredKeyedService(Type serviceType, object? serviceKey)
|
||||
{
|
||||
if (this._originalProvider is IKeyedServiceProvider keyedProvider)
|
||||
{
|
||||
return keyedProvider.GetRequiredKeyedService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("The original service provider does not support keyed services.");
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType)
|
||||
{
|
||||
// If the requested service is DurableTaskClient, return our instance
|
||||
if (serviceType == typeof(DurableTaskClient))
|
||||
{
|
||||
return this._client;
|
||||
}
|
||||
|
||||
// Otherwise try to get the service from the original provider
|
||||
return this._originalProvider.GetService(serviceType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Release History
|
||||
|
||||
## v1.0.0-preview.* (Unreleased)
|
||||
|
||||
- Initial public release.
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Transforms function metadata by registering durable agent functions for each configured agent.
|
||||
/// </summary>
|
||||
/// <remarks>This transformer adds both entity trigger and HTTP trigger functions for every agent registered in the application.</remarks>
|
||||
internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadataTransformer
|
||||
{
|
||||
private readonly ILogger<DurableAgentFunctionMetadataTransformer> _logger;
|
||||
private readonly IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> _agents;
|
||||
|
||||
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing
|
||||
private static readonly string s_builtInFunctionsScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location);
|
||||
#pragma warning restore IL3000
|
||||
|
||||
public DurableAgentFunctionMetadataTransformer(
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents,
|
||||
ILogger<DurableAgentFunctionMetadataTransformer> logger)
|
||||
{
|
||||
this._agents = agents ?? throw new ArgumentNullException(nameof(agents));
|
||||
this._logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string Name => nameof(DurableAgentFunctionMetadataTransformer);
|
||||
|
||||
public void Transform(IList<IFunctionMetadata> original)
|
||||
{
|
||||
this._logger.LogInformation("Transforming function metadata to add durable agent functions. Initial function count: {FunctionCount}", original.Count);
|
||||
|
||||
foreach (string agentName in this._agents.Keys)
|
||||
{
|
||||
this._logger.LogInformation("Registering functions for agent: {AgentName}", agentName);
|
||||
|
||||
// Each agent type gets its own entity trigger function.
|
||||
// We do this 1:1 mapping for improved telemetry.
|
||||
original.Add(CreateAgentTrigger(agentName));
|
||||
|
||||
// Each agent type gets its own HTTP trigger function.
|
||||
// TODO: Put this behind a configuration option.
|
||||
original.Add(CreateHttpTrigger(agentName, $"agents/{agentName}/run", nameof(BuiltInFunctions.RunAgentHttpAsync)));
|
||||
}
|
||||
}
|
||||
|
||||
private static DefaultFunctionMetadata CreateAgentTrigger(string name)
|
||||
{
|
||||
return new DefaultFunctionMetadata()
|
||||
{
|
||||
Name = AgentSessionId.ToEntityName(name),
|
||||
Language = "dotnet-isolated",
|
||||
RawBindings =
|
||||
[
|
||||
"""{"name":"dispatcher","type":"entityTrigger","direction":"In"}""",
|
||||
"""{"name":"client","type":"durableClient","direction":"In"}"""
|
||||
],
|
||||
EntryPoint = BuiltInFunctions.RunAgentEntityFunctionEntryPoint,
|
||||
ScriptFile = s_builtInFunctionsScriptFile,
|
||||
};
|
||||
}
|
||||
|
||||
private static DefaultFunctionMetadata CreateHttpTrigger(string name, string route, string dotnetMethodName)
|
||||
{
|
||||
return new DefaultFunctionMetadata()
|
||||
{
|
||||
Name = $"{name}_http",
|
||||
Language = "dotnet-isolated",
|
||||
RawBindings =
|
||||
[
|
||||
$$"""{"name":"req","type":"httpTrigger","direction":"In","authLevel":"function","methods": ["post"],"route":"{{route}}"}""",
|
||||
"""{"name":"$return","type":"http","direction":"Out"}""",
|
||||
"""{"name":"client","type":"durableClient","direction":"In"}"""
|
||||
],
|
||||
EntryPoint = BuiltInFunctions.RunAgentHttpFunctionEntryPoint,
|
||||
ScriptFile = s_builtInFunctionsScriptFile,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for the <see cref="DurableTaskClient"/> class.
|
||||
/// </summary>
|
||||
public static class DurableTaskClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a <see cref="DurableTaskClient"/> to a durable agent proxy.
|
||||
/// </summary>
|
||||
/// <param name="durableClient">The <see cref="DurableTaskClient"/> to convert.</param>
|
||||
/// <param name="context">The <see cref="FunctionContext"/> for the current function invocation.</param>
|
||||
/// <param name="agentName">The name of the agent.</param>
|
||||
/// <returns>A durable agent proxy.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="durableClient"/> or <paramref name="context"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="agentName"/> is null or empty.</exception>
|
||||
public static AIAgent AsDurableAgentProxy(
|
||||
this DurableTaskClient durableClient,
|
||||
FunctionContext context,
|
||||
string agentName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(durableClient);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentException.ThrowIfNullOrEmpty(agentName);
|
||||
|
||||
DefaultDurableAgentClient agentClient = ActivatorUtilities.CreateInstance<DefaultDurableAgentClient>(
|
||||
context.InstanceServices,
|
||||
durableClient);
|
||||
|
||||
return new DurableAIAgentProxy(agentName, agentClient);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for the <see cref="FunctionsApplicationBuilder"/> class.
|
||||
/// </summary>
|
||||
public static class FunctionsApplicationBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the application to use durable agents with a builder pattern.
|
||||
/// </summary>
|
||||
/// <param name="builder">The functions application builder.</param>
|
||||
/// <param name="configure">A delegate to configure the durable agents.</param>
|
||||
/// <returns>The functions application builder.</returns>
|
||||
public static FunctionsApplicationBuilder ConfigureDurableAgents(
|
||||
this FunctionsApplicationBuilder builder,
|
||||
Action<DurableAgentsOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
// The main agent services registration is done in Microsoft.DurableTask.Agents.
|
||||
builder.Services.ConfigureDurableAgents(configure);
|
||||
|
||||
builder.Services.TryAddSingleton<IFunctionMetadataTransformer, DurableAgentFunctionMetadataTransformer>();
|
||||
|
||||
// Handling of built-in function execution for Agent HTTP or Entity invocations.
|
||||
builder.UseWhen<BuiltInFunctionExecutionMiddleware>(static context =>
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal));
|
||||
builder.Services.AddSingleton<BuiltInFunctionExecutor>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<!-- CA2007: This rule should generally be suppressed in Durable Task libraries. Also, this is not library code. -->
|
||||
<NoWarn>$(NoWarn);CA2007</NoWarn>
|
||||
<!-- Disable packing until we are ready to release this as a nuget -->
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<!-- NuGet package metadata -->
|
||||
<PropertyGroup>
|
||||
<Title>Azure Functions extensions for Microsoft Agent Framework</Title>
|
||||
<Description>Provides durable agent hosting and orchestration support for Microsoft Agent Framework workloads.</Description>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Project references -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Public dependencies -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Internals -->
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Ensure README.md is included in the NuGet package -->
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="/" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Invocation;
|
||||
using Microsoft.Azure.Functions.Worker.Middleware;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// This middleware sets a custom function executor for invocation of functions that have the built-in method as the entrypoint.
|
||||
/// </summary>
|
||||
internal sealed class BuiltInFunctionExecutionMiddleware(BuiltInFunctionExecutor builtInFunctionExecutor)
|
||||
: IFunctionsWorkerMiddleware
|
||||
{
|
||||
private readonly BuiltInFunctionExecutor _builtInFunctionExecutor = builtInFunctionExecutor;
|
||||
|
||||
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
|
||||
{
|
||||
// We set our custom function executor for this invocation.
|
||||
context.Features.Set<IFunctionExecutor>(this._builtInFunctionExecutor);
|
||||
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
# Microsoft.Agents.AI.Hosting.AzureFunctions
|
||||
|
||||
This package adds Azure Functions integration and serverless hosting for Microsoft Agent Framework on Azure Functions. It builds upon the [Microsoft.Agents.AI.DurableTask](https://www.nuget.org/packages/Microsoft.Agents.AI.DurableTask) package to provide the following capabilities:
|
||||
|
||||
- Stateful, durable execution of agents in distributed, serverless environments
|
||||
- Automatic conversation history management in supported [Durable Functions backends](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-task-hubs/choose-durable-functions-backends)
|
||||
- Long-running agent workflows as "durable orchestrator" functions
|
||||
- Tools and [dashboards](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler-dashboard) for managing and monitoring agents and agent workflows
|
||||
|
||||
## Install the package
|
||||
|
||||
From the command-line:
|
||||
|
||||
```bash
|
||||
dotnet add package Microsoft.Agents.AI.Hosting.AzureFunctions
|
||||
```
|
||||
|
||||
Or directly in your project file:
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" Version="[CURRENTVERSION]" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
For a comprehensive tour of all the functionality, concepts, and APIs, check out the [Azure Functions samples](https://github.com/microsoft/agent-framework/tree/main/dotnet/samples/AzureFunctions) in the [Durable extensions for Agent Framework repository](https://github.com/microsoft/agent-framework).
|
||||
|
||||
### Hosting single agents
|
||||
|
||||
This package provides a `ConfigureDurableAgents` extension method on the `FunctionsApplicationBuilder` class to configure the application to host Microsoft Agent Framework agents. These hosted agents are automatically registered as durable entities with the Durable Task runtime and can be invoked via HTTP or Durable Task orchestrator functions.
|
||||
|
||||
```csharp
|
||||
// Create agents using the standard Microsoft Agent Framework.
|
||||
// Invocable via HTTP via http://localhost:7071/api/agents/SpamDetectionAgent/run
|
||||
AIAgent spamDetector = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.CreateAIAgent(
|
||||
instructions: "You are a spam detection assistant that identifies spam emails.",
|
||||
name: "SpamDetectionAgent");
|
||||
|
||||
AIAgent emailAssistant = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.CreateAIAgent(
|
||||
instructions: "You are an email assistant that helps users draft responses to emails with professionalism.",
|
||||
name: "EmailAssistantAgent");
|
||||
|
||||
// Configure the Functions application to host the agents.
|
||||
using IHost app = FunctionsApplication
|
||||
.CreateBuilder(args)
|
||||
.ConfigureFunctionsWebApplication()
|
||||
.ConfigureDurableAgents(options =>
|
||||
{
|
||||
options.AddAIAgent(spamDetector);
|
||||
options.AddAIAgent(emailAssistant);
|
||||
})
|
||||
.Build();
|
||||
app.Run();
|
||||
```
|
||||
|
||||
By default, each agent can be invoked via a built-in HTTP trigger function at the route `http[s]://[host]/api/agents/{agentName}/run`.
|
||||
|
||||
### Orchestrating hosted agents
|
||||
|
||||
This package also provides a set of extension methods such as `GetAgent` on the [`TaskOrchestrationContext`](https://learn.microsoft.com/dotnet/api/microsoft.durabletask.taskorchestrationcontext) class for interacting with hosted agents within orchestrations.
|
||||
|
||||
```csharp
|
||||
[Function(nameof(SpamDetectionOrchestration))]
|
||||
public static async Task<string> SpamDetectionOrchestration(
|
||||
[OrchestrationTrigger] TaskOrchestrationContext context)
|
||||
{
|
||||
Email email = context.GetInput<Email>() ?? throw new InvalidOperationException("Email is required");
|
||||
|
||||
// Get the spam detection agent
|
||||
DurableAIAgent spamDetectionAgent = context.GetAgent("SpamDetectionAgent");
|
||||
AgentThread spamThread = spamDetectionAgent.GetNewThread();
|
||||
|
||||
// Step 1: Check if the email is spam
|
||||
AgentRunResponse<DetectionResult> spamDetectionResponse = await spamDetectionAgent.RunAsync<DetectionResult>(
|
||||
message:
|
||||
$"""
|
||||
Analyze this email for spam content and return a JSON response with 'is_spam' (boolean) and 'reason' (string) fields:
|
||||
Email ID: {email.EmailId}
|
||||
Content: {email.EmailContent}
|
||||
""",
|
||||
thread: spamThread);
|
||||
DetectionResult result = spamDetectionResponse.Result;
|
||||
|
||||
// Step 2: Conditional logic based on spam detection result
|
||||
if (result.IsSpam)
|
||||
{
|
||||
// Handle spam email
|
||||
return await context.CallActivityAsync<string>(nameof(HandleSpamEmail), result.Reason);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Generate and send response for legitimate email
|
||||
DurableAIAgent emailAssistantAgent = context.GetAgent("EmailAssistantAgent");
|
||||
AgentThread emailThread = emailAssistantAgent.GetNewThread();
|
||||
|
||||
AgentRunResponse<EmailResponse> emailAssistantResponse = await emailAssistantAgent.RunAsync<EmailResponse>(
|
||||
message:
|
||||
$"""
|
||||
Draft a professional response to this email. Return a JSON response with a 'response' field containing the reply:
|
||||
|
||||
Email ID: {email.EmailId}
|
||||
Content: {email.EmailContent}
|
||||
""",
|
||||
thread: emailThread);
|
||||
|
||||
EmailResponse emailResponse = emailAssistantResponse.Result;
|
||||
return await context.CallActivityAsync<string>(nameof(SendEmail), emailResponse.Response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Scheduling orchestrations from custom code tools
|
||||
|
||||
Agents can also schedule and interact with orchestrations from custom code tools. This is useful for long-running tool use cases where orchestrations need to be executed in the context of the agent.
|
||||
|
||||
The `DurableAgentContext.Current` *AsyncLocal* property provides access to the current agent context, which can be used to schedule and interact with orchestrations.
|
||||
|
||||
```csharp
|
||||
class Tools
|
||||
{
|
||||
[Description("Starts a content generation workflow and returns the instance ID for tracking.")]
|
||||
public string StartContentGenerationWorkflow(
|
||||
[Description("The topic for content generation")] string topic)
|
||||
{
|
||||
// ContentGenerationWorkflow is an orchestrator function defined in the same project.
|
||||
string instanceId = DurableAgentContext.Current.ScheduleNewOrchestration(
|
||||
name: nameof(ContentGenerationWorkflow),
|
||||
input: topic);
|
||||
|
||||
// Return the instance ID so that it gets added to the LLM context.
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
[Description("Gets the status of a content generation workflow.")]
|
||||
public async Task<OrchestrationMetadata> GetContentGenerationStatus(
|
||||
[Description("The instance ID of the workflow to check")] string instanceId,
|
||||
[Description("Whether to include detailed information")] bool includeDetails = true)
|
||||
{
|
||||
OrchestrationMetadata? status = await DurableAgentContext.Current.Client.GetOrchestrationStatusAsync(
|
||||
instanceId,
|
||||
includeDetails);
|
||||
return status ?? throw new InvalidOperationException($"Workflow instance '{instanceId}' not found.");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These tools are registered with the agent using the `tools` parameter when creating the agent.
|
||||
|
||||
```csharp
|
||||
Tools tools = new();
|
||||
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.CreateAIAgent(
|
||||
instructions: "You are a content generation assistant that helps users generate content.",
|
||||
name: "ContentGenerationAgent",
|
||||
tools: [
|
||||
AIFunctionFactory.Create(tools.StartContentGenerationWorkflow),
|
||||
AIFunctionFactory.Create(tools.GetContentGenerationStatus)
|
||||
]);
|
||||
|
||||
using IHost app = FunctionsApplication
|
||||
.CreateBuilder(args)
|
||||
.ConfigureFunctionsWebApplication()
|
||||
.ConfigureDurableAgents(options => options.AddAIAgent(agent))
|
||||
.Build();
|
||||
app.Run();
|
||||
```
|
||||
|
||||
## Feedback & Contributing
|
||||
|
||||
We welcome feedback and contributions in [our GitHub repo](https://github.com/microsoft/agent-framework).
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.DurableTask.Entities;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.UnitTests;
|
||||
|
||||
public sealed class AgentSessionIdTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseValidSessionId()
|
||||
{
|
||||
const string Name = "test-agent";
|
||||
const string Key = "12345";
|
||||
string sessionIdString = $"@dafx-{Name}@{Key}";
|
||||
AgentSessionId sessionId = AgentSessionId.Parse(sessionIdString);
|
||||
|
||||
Assert.Equal(Name, sessionId.Name);
|
||||
Assert.Equal(Key, sessionId.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseInvalidSessionId()
|
||||
{
|
||||
const string InvalidSessionIdString = "@test-agent@12345"; // Missing "dafx-" prefix
|
||||
Assert.Throws<ArgumentException>(() => AgentSessionId.Parse(InvalidSessionIdString));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromEntityId()
|
||||
{
|
||||
const string Name = "test-agent";
|
||||
const string Key = "12345";
|
||||
|
||||
EntityInstanceId entityId = new($"dafx-{Name}", Key);
|
||||
AgentSessionId sessionId = (AgentSessionId)entityId;
|
||||
|
||||
Assert.Equal(Name, sessionId.Name);
|
||||
Assert.Equal(Key, sessionId.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromInvalidEntityId()
|
||||
{
|
||||
const string Name = "test-agent";
|
||||
const string Key = "12345";
|
||||
|
||||
EntityInstanceId entityId = new(Name, Key); // Missing "dafx-" prefix
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
{
|
||||
// This assignment should throw an exception because
|
||||
// the entity ID is not a valid agent session ID.
|
||||
AgentSessionId sessionId = entityId;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.UnitTests;
|
||||
|
||||
public sealed class DurableAgentThreadTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuiltInSerialization()
|
||||
{
|
||||
AgentSessionId sessionId = AgentSessionId.WithRandomKey("test-agent");
|
||||
AgentThread thread = new DurableAgentThread(sessionId);
|
||||
|
||||
JsonElement serializedThread = thread.Serialize();
|
||||
|
||||
// Expected format: "{\"sessionId\":\"@dafx-test-agent@<random-key>\"}"
|
||||
string expectedSerializedThread = $"{{\"sessionId\":\"@dafx-{sessionId.Name}@{sessionId.Key}\"}}";
|
||||
Assert.Equal(expectedSerializedThread, serializedThread.ToString());
|
||||
|
||||
DurableAgentThread deserializedThread = DurableAgentThread.Deserialize(serializedThread);
|
||||
Assert.Equal(sessionId, deserializedThread.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void STJSerialization()
|
||||
{
|
||||
AgentSessionId sessionId = AgentSessionId.WithRandomKey("test-agent");
|
||||
AgentThread thread = new DurableAgentThread(sessionId);
|
||||
|
||||
// Need to specify the type explicitly because STJ, unlike other serializers,
|
||||
// does serialization based on the static type of the object, not the runtime type.
|
||||
string serializedThread = JsonSerializer.Serialize(thread, typeof(DurableAgentThread));
|
||||
|
||||
// Expected format: "{\"sessionId\":\"@dafx-test-agent@<random-key>\"}"
|
||||
string expectedSerializedThread = $"{{\"sessionId\":\"@dafx-{sessionId.Name}@{sessionId.Key}\"}}";
|
||||
Assert.Equal(expectedSerializedThread, serializedThread);
|
||||
|
||||
DurableAgentThread? deserializedThread = JsonSerializer.Deserialize<DurableAgentThread>(serializedThread);
|
||||
Assert.NotNull(deserializedThread);
|
||||
Assert.Equal(sessionId, deserializedThread.SessionId);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests;
|
||||
|
||||
public sealed class DurableAgentFunctionMetadataTransformerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0)] // Empty original metadata list
|
||||
[InlineData(3)] // Non-empty original metadata list
|
||||
public void Transform_AddsAgentAndHttpTriggers_ForEachAgent(int initialMetadataEntryCount)
|
||||
{
|
||||
Dictionary<string, Func<IServiceProvider, AIAgent>> agents = new()
|
||||
{
|
||||
{ "testAgent", _ => null! }
|
||||
};
|
||||
DurableAgentFunctionMetadataTransformer transformer = new(agents, GetTestLogger());
|
||||
List<IFunctionMetadata> metadataList = BuildFunctionMetadataList(initialMetadataEntryCount);
|
||||
|
||||
transformer.Transform(metadataList);
|
||||
|
||||
Assert.Equal(initialMetadataEntryCount + 2, metadataList.Count); // each agent adds 2 functions (http + entity).
|
||||
|
||||
DefaultFunctionMetadata agentTrigger = Assert.IsType<DefaultFunctionMetadata>(metadataList[initialMetadataEntryCount]);
|
||||
Assert.Equal("dafx-testAgent", agentTrigger.Name);
|
||||
Assert.Equal("dotnet-isolated", agentTrigger.Language);
|
||||
Assert.Contains("type\":\"entityTrigger", agentTrigger.RawBindings![0]);
|
||||
|
||||
DefaultFunctionMetadata httpTrigger = Assert.IsType<DefaultFunctionMetadata>(metadataList[initialMetadataEntryCount + 1]);
|
||||
Assert.Equal("testAgent_http", httpTrigger.Name);
|
||||
Assert.Equal("dotnet-isolated", httpTrigger.Language);
|
||||
Assert.Contains("type\":\"httpTrigger", httpTrigger.RawBindings![0]);
|
||||
Assert.Contains("route\":\"agents/testAgent/run", httpTrigger.RawBindings[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transform_AddsTriggers_ForMultipleAgents()
|
||||
{
|
||||
Dictionary<string, Func<IServiceProvider, AIAgent>> agents = new()
|
||||
{
|
||||
{ "agentA", _ => null! },
|
||||
{ "agentB", _ => null! },
|
||||
{ "agentC", _ => null! }
|
||||
};
|
||||
DurableAgentFunctionMetadataTransformer transformer = new(agents, GetTestLogger());
|
||||
const int InitialMetadataEntryCount = 2;
|
||||
List<IFunctionMetadata> metadataList = BuildFunctionMetadataList(InitialMetadataEntryCount);
|
||||
|
||||
transformer.Transform(metadataList);
|
||||
|
||||
Assert.Equal(InitialMetadataEntryCount + (agents.Count * 2), metadataList.Count);
|
||||
|
||||
foreach (string agentName in agents.Keys)
|
||||
{
|
||||
// The agent's entity trigger name is prefixed with "dafx-"
|
||||
DefaultFunctionMetadata entityMeta =
|
||||
Assert.IsType<DefaultFunctionMetadata>(
|
||||
Assert.Single(metadataList, m => m.Name == "dafx-" + agentName));
|
||||
Assert.NotNull(entityMeta.RawBindings);
|
||||
Assert.Contains("entityTrigger", entityMeta.RawBindings[0]);
|
||||
|
||||
DefaultFunctionMetadata httpMeta =
|
||||
Assert.IsType<DefaultFunctionMetadata>(
|
||||
Assert.Single(metadataList, m => m.Name == agentName + "_http"));
|
||||
Assert.NotNull(httpMeta.RawBindings);
|
||||
Assert.Contains("httpTrigger", httpMeta.RawBindings[0]);
|
||||
Assert.Contains($"agents/{agentName}/run", httpMeta.RawBindings[0]);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<IFunctionMetadata> BuildFunctionMetadataList(int numberOfFunctions)
|
||||
{
|
||||
List<IFunctionMetadata> list = [];
|
||||
for (int i = 0; i < numberOfFunctions; i++)
|
||||
{
|
||||
list.Add(new DefaultFunctionMetadata
|
||||
{
|
||||
Language = "dotnet-isolated",
|
||||
Name = $"SingleAgentOrchestration{i + 1}",
|
||||
EntryPoint = "MyApp.Functions.SingleAgentOrchestration",
|
||||
RawBindings =
|
||||
[
|
||||
"{\r\n \"name\": \"context\",\r\n \"direction\": \"In\",\r\n \"type\": \"orchestrationTrigger\",\r\n \"properties\": {}\r\n }"
|
||||
],
|
||||
ScriptFile = "MyApp.dll"
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static NullLogger<DurableAgentFunctionMetadataTransformer> GetTestLogger() => new();
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user