Merge branch 'main' into feature-foundry-agents

This commit is contained in:
Chris
2025-11-12 19:08:34 -08:00
committed by GitHub
Unverified
216 changed files with 18178 additions and 2 deletions
@@ -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,124 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask.State;
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");
}
this.State.Data.ConversationHistory.Add(DurableAgentStateRequest.FromRunRequest(request));
foreach (ChatMessage msg in request.Messages)
{
logger.LogAgentRequest(sessionId, msg.Role, msg.Text);
}
// 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(
this.State.Data.ConversationHistory.SelectMany(e => e.Messages).Select(m => m.ToChatMessage()),
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
this.State.Data.ConversationHistory.Add(
DurableAgentStateResponse.FromRunResponse(request.CorrelationId, response));
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);
}
return response;
}
finally
{
// Clear the current agent context
DurableAgentContext.ClearCurrent();
}
}
}
@@ -0,0 +1,83 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask.State;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.Entities;
using Microsoft.Extensions.Logging;
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;
private readonly ILogger _logger;
internal AgentRunHandle(
DurableTaskClient client,
ILogger logger,
AgentSessionId sessionId,
string correlationId)
{
this._client = client;
this._logger = logger;
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
this._logger.LogStartPollingForResponse(this.SessionId, this.CorrelationId);
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;
if (state?.Data.ConversationHistory is not null)
{
// Look for an agent response with matching CorrelationId
DurableAgentStateResponse? response = state.Data.ConversationHistory
.OfType<DurableAgentStateResponse>()
.FirstOrDefault(r => r.CorrelationId == this.CorrelationId);
if (response is not null)
{
this._logger.LogDonePollingForResponse(this.SessionId, this.CorrelationId);
return response.ToRunResponse();
}
}
// 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,31 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.DurableTask.Client;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Microsoft.Agents.AI.DurableTask;
internal class DefaultDurableAgentClient(DurableTaskClient client, ILoggerFactory loggerFactory) : IDurableAgentClient
{
private readonly DurableTaskClient _client = client ?? throw new ArgumentNullException(nameof(client));
private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<DefaultDurableAgentClient>();
public async Task<AgentRunHandle> RunAgentAsync(
AgentSessionId sessionId,
RunRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
this._logger.LogSignallingAgent(sessionId);
await this._client.Entities.SignalEntityAsync(
sessionId,
nameof(AgentEntity.RunAgentAsync),
request,
cancellation: cancellationToken);
return new AgentRunHandle(this._client, this._logger, sessionId, request.CorrelationId);
}
}
@@ -0,0 +1,233 @@
// 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>(
messages: [new ChatMessage(ChatRole.User, message) { CreatedAt = DateTimeOffset.UtcNow }],
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,99 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.DurableTask.State;
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)
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // same as AgentAbstractionsJsonUtilities and AIJsonUtilities
};
// Chain in shared abstractions resolver (Microsoft.Extensions.AI + Agent abstractions) so dependent types are covered.
options.TypeInfoResolverChain.Clear();
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.TypeInfoResolverChain.Add(JsonContext.Default.Options.TypeInfoResolver!);
if (JsonSerializer.IsReflectionEnabledByDefault)
{
options.Converters.Add(new JsonStringEnumConverter());
}
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(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,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>
internal 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,49 @@
// 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);
[LoggerMessage(
EventId = 3,
Level = LogLevel.Information,
Message = "Signalling agent with session ID '{SessionId}'")]
public static partial void LogSignallingAgent(this ILogger logger, AgentSessionId sessionId);
[LoggerMessage(
EventId = 4,
Level = LogLevel.Information,
Message = "Polling agent with session ID '{SessionId}' for response with correlation ID '{CorrelationId}'")]
public static partial void LogStartPollingForResponse(this ILogger logger, AgentSessionId sessionId, string correlationId);
[LoggerMessage(
EventId = 5,
Level = LogLevel.Information,
Message = "Found response for agent with session ID '{SessionId}' with correlation ID '{CorrelationId}'")]
public static partial void LogDonePollingForResponse(this ILogger logger, AgentSessionId sessionId, string correlationId);
}
@@ -0,0 +1,39 @@
<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>
</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 Task extension 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` 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` 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/).
## 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; } = Guid.NewGuid().ToString("N");
/// <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) { CreatedAt = DateTimeOffset.UtcNow }], 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,159 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Agents.AI.DurableTask.State;
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 sealed 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;
}
if (targetType == typeof(DurableAgentState))
{
return JsonSerializer.Deserialize(data, DurableAgentStateJsonContext.Default.DurableAgentState);
}
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;
}
if (value is DurableAgentState durableAgentState)
{
return JsonSerializer.Serialize(durableAgentState, DurableAgentStateJsonContext.Default.DurableAgentState);
}
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,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents the state of a durable agent, including its conversation history.
/// </summary>
[JsonConverter(typeof(DurableAgentStateJsonConverter))]
internal sealed class DurableAgentState
{
/// <summary>
/// Gets the data of the durable agent.
/// </summary>
[JsonPropertyName("data")]
public DurableAgentStateData Data { get; init; } = new();
/// <summary>
/// Gets the schema version of the durable agent state.
/// </summary>
/// <remarks>
/// The version is specified in semver (i.e. "major.minor.patch") format.
/// </remarks>
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; init; } = "1.0.0";
}
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Base class for durable agent state content types.
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(DurableAgentStateDataContent), "data")]
[JsonDerivedType(typeof(DurableAgentStateErrorContent), "error")]
[JsonDerivedType(typeof(DurableAgentStateFunctionCallContent), "functionCall")]
[JsonDerivedType(typeof(DurableAgentStateFunctionResultContent), "functionResult")]
[JsonDerivedType(typeof(DurableAgentStateHostedFileContent), "hostedFile")]
[JsonDerivedType(typeof(DurableAgentStateHostedVectorStoreContent), "hostedVectorStore")]
[JsonDerivedType(typeof(DurableAgentStateTextContent), "text")]
[JsonDerivedType(typeof(DurableAgentStateTextReasoningContent), "reasoning")]
[JsonDerivedType(typeof(DurableAgentStateUriContent), "uri")]
[JsonDerivedType(typeof(DurableAgentStateUsageContent), "usage")]
[JsonDerivedType(typeof(DurableAgentStateUnknownContent), "unknown")]
internal abstract class DurableAgentStateContent
{
/// <summary>
/// Gets any additional data found during deserialization that does not map to known properties.
/// </summary>
[JsonExtensionData]
public IDictionary<string, JsonElement>? ExtensionData { get; set; }
/// <summary>
/// Converts this durable agent state content to an <see cref="AIContent"/>.
/// </summary>
/// <returns>A converted <see cref="AIContent"/> instance.</returns>
public abstract AIContent ToAIContent();
/// <summary>
/// Creates a <see cref="DurableAgentStateContent"/> from an <see cref="AIContent"/>.
/// </summary>
/// <param name="content">The <see cref="AIContent"/> to convert.</param>
/// <returns>A <see cref="DurableAgentStateContent"/> representing the original <see cref="AIContent"/>.</returns>
public static DurableAgentStateContent FromAIContent(AIContent content)
{
return content switch
{
DataContent dataContent => DurableAgentStateDataContent.FromDataContent(dataContent),
ErrorContent errorContent => DurableAgentStateErrorContent.FromErrorContent(errorContent),
FunctionCallContent functionCallContent => DurableAgentStateFunctionCallContent.FromFunctionCallContent(functionCallContent),
FunctionResultContent functionResultContent => DurableAgentStateFunctionResultContent.FromFunctionResultContent(functionResultContent),
HostedFileContent hostedFileContent => DurableAgentStateHostedFileContent.FromHostedFileContent(hostedFileContent),
HostedVectorStoreContent hostedVectorStoreContent => DurableAgentStateHostedVectorStoreContent.FromHostedVectorStoreContent(hostedVectorStoreContent),
TextContent textContent => DurableAgentStateTextContent.FromTextContent(textContent),
TextReasoningContent textReasoningContent => DurableAgentStateTextReasoningContent.FromTextReasoningContent(textReasoningContent),
UriContent uriContent => DurableAgentStateUriContent.FromUriContent(uriContent),
UsageContent usageContent => DurableAgentStateUsageContent.FromUsageContent(usageContent),
_ => DurableAgentStateUnknownContent.FromUnknownContent(content)
};
}
}
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents the data of a durable agent, including its conversation history.
/// </summary>
internal sealed class DurableAgentStateData
{
/// <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>
[JsonPropertyName("conversationHistory")]
public IList<DurableAgentStateEntry> ConversationHistory { get; init; } = [];
/// <summary>
/// Gets any additional data found during deserialization that does not map to known properties.
/// </summary>
[JsonExtensionData]
public IDictionary<string, JsonElement>? ExtensionData { get; set; }
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents a durable agent state content that contains data content.
/// </summary>
internal sealed class DurableAgentStateDataContent : DurableAgentStateContent
{
/// <summary>
/// Gets the URI of the data content.
/// </summary>
[JsonPropertyName("uri")]
public required string Uri { get; init; }
/// <summary>
/// Gets the media type of the data content.
/// </summary>
[JsonPropertyName("mediaType")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? MediaType { get; init; }
/// <summary>
/// Creates a <see cref="DurableAgentStateDataContent"/> from a <see cref="DataContent"/>.
/// </summary>
/// <param name="content">The <see cref="DataContent"/> to convert.</param>
/// <returns>A <see cref="DurableAgentStateDataContent"/> representing the original <see cref="DataContent"/>.</returns>
public static DurableAgentStateDataContent FromDataContent(DataContent content)
{
return new DurableAgentStateDataContent()
{
MediaType = content.MediaType,
Uri = content.Uri
};
}
/// <inheritdoc/>
public override AIContent ToAIContent()
{
return new DataContent(this.Uri, this.MediaType);
}
}
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents a single entry in the durable agent state, which can either be a
/// user/system request or agent response.
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(DurableAgentStateRequest), "request")]
[JsonDerivedType(typeof(DurableAgentStateResponse), "response")]
internal abstract class DurableAgentStateEntry
{
/// <summary>
/// Gets the correlation ID for this entry.
/// </summary>
/// <remarks>
/// This ID is used to correlate <see cref="DurableAgentStateResponse"/> back to its
/// <see cref="DurableAgentStateRequest"/>.
/// </remarks>
[JsonPropertyName("correlationId")]
public required string CorrelationId { get; init; }
/// <summary>
/// Gets the timestamp when this entry was created.
/// </summary>
[JsonPropertyName("createdAt")]
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Gets the list of messages associated with this entry, in chronological order.
/// </summary>
[JsonPropertyName("messages")]
public IReadOnlyList<DurableAgentStateMessage> Messages { get; init; } = [];
/// <summary>
/// Gets any additional data found during deserialization that does not map to known properties.
/// </summary>
[JsonExtensionData]
public IDictionary<string, JsonElement>? ExtensionData { get; set; }
}
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents durable agent state content that contains error content.
/// </summary>
internal sealed class DurableAgentStateErrorContent : DurableAgentStateContent
{
/// <summary>
/// Gets the error message.
/// </summary>
[JsonPropertyName("message")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Message { get; init; }
/// <summary>
/// Gets the error code.
/// </summary>
[JsonPropertyName("errorCode")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ErrorCode { get; init; }
/// <summary>
/// Gets the error details.
/// </summary>
[JsonPropertyName("details")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Details { get; init; }
/// <summary>
/// Creates a <see cref="DurableAgentStateErrorContent"/> from an <see cref="ErrorContent"/>.
/// </summary>
/// <param name="content">The <see cref="ErrorContent"/> to convert.</param>
/// <returns>A <see cref="DurableAgentStateErrorContent"/> representing the original
/// <see cref="ErrorContent"/>.</returns>
public static DurableAgentStateErrorContent FromErrorContent(ErrorContent content)
{
return new DurableAgentStateErrorContent()
{
Details = content.Details,
ErrorCode = content.ErrorCode,
Message = content.Message
};
}
/// <inheritdoc/>
public override AIContent ToAIContent()
{
return new ErrorContent(this.Message)
{
Details = this.Details,
ErrorCode = this.ErrorCode
};
}
}
@@ -0,0 +1,63 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Durable agent state content representing a function call.
/// </summary>
internal sealed class DurableAgentStateFunctionCallContent : DurableAgentStateContent
{
/// <summary>
/// The function call arguments.
/// </summary>
/// TODO: Consider ensuring that empty dictionaries are omitted from serialization.
[JsonPropertyName("arguments")]
public required IReadOnlyDictionary<string, object?> Arguments { get; init; } =
ImmutableDictionary<string, object?>.Empty;
/// <summary>
/// Gets the function call identifier.
/// </summary>
/// <remarks>
/// This is used to correlate this function call with its resulting
/// <see cref="DurableAgentStateFunctionResultContent"/>.
/// </remarks>
[JsonPropertyName("callId")]
public required string CallId { get; init; }
/// <summary>
/// Gets the function name.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Creates a <see cref="DurableAgentStateFunctionCallContent"/> from a <see cref="FunctionCallContent"/>.
/// </summary>
/// <param name="content">The <see cref="FunctionCallContent"/> to convert.</param>
/// <returns>
/// A <see cref="DurableAgentStateFunctionCallContent"/> representing the original content.
/// </returns>
public static DurableAgentStateFunctionCallContent FromFunctionCallContent(FunctionCallContent content)
{
return new DurableAgentStateFunctionCallContent()
{
Arguments = content.Arguments?.ToImmutableDictionary() ?? ImmutableDictionary<string, object?>.Empty,
CallId = content.CallId,
Name = content.Name
};
}
/// <inheritdoc/>
public override AIContent ToAIContent()
{
return new FunctionCallContent(
this.CallId,
this.Name,
new Dictionary<string, object?>(this.Arguments));
}
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents the function result content for a durable agent state response.
/// </summary>
internal sealed class DurableAgentStateFunctionResultContent : DurableAgentStateContent
{
/// <summary>
/// Gets the function call identifier.
/// </summary>
/// <remarks>
/// This is used to correlate this function result with its originating
/// <see cref="DurableAgentStateFunctionCallContent"/>.
/// </remarks>
[JsonPropertyName("callId")]
public required string CallId { get; init; }
/// <summary>
/// Gets the function result.
/// </summary>
[JsonPropertyName("result")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? Result { get; init; }
/// <summary>
/// Creates a <see cref="DurableAgentStateFunctionResultContent"/> from a <see cref="FunctionResultContent"/>.
/// </summary>
/// <param name="content">The <see cref="FunctionResultContent"/> to convert.</param>
/// <returns>A <see cref="DurableAgentStateFunctionResultContent"/> representing the original content.</returns>
public static DurableAgentStateFunctionResultContent FromFunctionResultContent(FunctionResultContent content)
{
return new DurableAgentStateFunctionResultContent()
{
CallId = content.CallId,
Result = content.Result
};
}
/// <inheritdoc/>
public override AIContent ToAIContent()
{
return new FunctionResultContent(this.CallId, this.Result);
}
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents durable agent state content that contains hosted file content.
/// </summary>
internal sealed class DurableAgentStateHostedFileContent : DurableAgentStateContent
{
/// <summary>
/// Gets the file ID of the hosted file content.
/// </summary>
[JsonPropertyName("fileId")]
public required string FileId { get; init; }
/// <summary>
/// Creates a <see cref="DurableAgentStateHostedFileContent"/> from a <see cref="HostedFileContent"/>.
/// </summary>
/// <param name="content">The <see cref="HostedFileContent"/> to convert.</param>
/// <returns>
/// A <see cref="DurableAgentStateHostedFileContent"/> representing the original <see cref="HostedFileContent"/>.
/// </returns>
public static DurableAgentStateHostedFileContent FromHostedFileContent(HostedFileContent content)
{
return new DurableAgentStateHostedFileContent()
{
FileId = content.FileId
};
}
/// <inheritdoc/>
public override AIContent ToAIContent()
{
return new HostedFileContent(this.FileId);
}
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents durable agent state content that contains hosted vector store content.
/// </summary>
internal sealed class DurableAgentStateHostedVectorStoreContent : DurableAgentStateContent
{
/// <summary>
/// Gets the vector store ID of the hosted vector store content.
/// </summary>
[JsonPropertyName("vectorStoreId")]
public required string VectorStoreId { get; init; }
/// <summary>
/// Creates a <see cref="DurableAgentStateHostedVectorStoreContent"/> from a <see cref="HostedVectorStoreContent"/>.
/// </summary>
/// <param name="content">The <see cref="HostedVectorStoreContent"/> to convert.</param>
/// <returns>
/// A <see cref="DurableAgentStateHostedVectorStoreContent"/> representing the original <see cref="HostedVectorStoreContent"/>.
/// </returns>
public static DurableAgentStateHostedVectorStoreContent FromHostedVectorStoreContent(HostedVectorStoreContent content)
{
return new DurableAgentStateHostedVectorStoreContent()
{
VectorStoreId = content.VectorStoreId
};
}
/// <inheritdoc/>
public override AIContent ToAIContent()
{
return new HostedVectorStoreContent(this.VectorStoreId);
}
}
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.DurableTask.State;
[JsonSourceGenerationOptions(WriteIndented = false)]
[JsonSerializable(typeof(DurableAgentState))]
[JsonSerializable(typeof(DurableAgentStateContent))]
[JsonSerializable(typeof(DurableAgentStateData))]
[JsonSerializable(typeof(DurableAgentStateEntry))]
[JsonSerializable(typeof(DurableAgentStateMessage))]
// Function call and result content
[JsonSerializable(typeof(Dictionary<string, object>))]
[JsonSerializable(typeof(IDictionary<string, object?>))]
[JsonSerializable(typeof(JsonDocument))]
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(JsonNode))]
[JsonSerializable(typeof(JsonObject))]
[JsonSerializable(typeof(JsonValue))]
[JsonSerializable(typeof(JsonArray))]
[JsonSerializable(typeof(IEnumerable<string>))]
[JsonSerializable(typeof(char))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(short))]
[JsonSerializable(typeof(long))]
[JsonSerializable(typeof(uint))]
[JsonSerializable(typeof(ushort))]
[JsonSerializable(typeof(ulong))]
[JsonSerializable(typeof(float))]
[JsonSerializable(typeof(double))]
[JsonSerializable(typeof(decimal))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(TimeSpan))]
[JsonSerializable(typeof(DateTime))]
[JsonSerializable(typeof(DateTimeOffset))]
internal sealed partial class DurableAgentStateJsonContext : JsonSerializerContext
{
}
@@ -0,0 +1,71 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// JSON converter for <see cref="DurableAgentState"/> which performs schema version checks before deserialization.
/// </summary>
internal sealed class DurableAgentStateJsonConverter : JsonConverter<DurableAgentState>
{
private const string SchemaVersionPropertyName = "schemaVersion";
private const string DataPropertyName = "data";
/// <inheritdoc/>
public override DurableAgentState? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
JsonElement? element = JsonSerializer.Deserialize(
ref reader,
DurableAgentStateJsonContext.Default.JsonElement);
if (element is null)
{
throw new JsonException("The durable agent state is not valid JSON.");
}
if (!element.Value.TryGetProperty(SchemaVersionPropertyName, out JsonElement versionElement))
{
throw new InvalidOperationException("The durable agent state is missing the 'schemaVersion' property.");
}
if (!Version.TryParse(versionElement.GetString(), out Version? schemaVersion))
{
throw new InvalidOperationException("The durable agent state has an invalid 'schemaVersion' property.");
}
if (schemaVersion.Major != 1)
{
throw new InvalidOperationException($"The durable agent state schema version '{schemaVersion}' is not supported.");
}
if (!element.Value.TryGetProperty(DataPropertyName, out JsonElement dataElement))
{
throw new InvalidOperationException("The durable agent state is missing the 'data' property.");
}
DurableAgentStateData? data = dataElement.Deserialize(
DurableAgentStateJsonContext.Default.DurableAgentStateData);
return new DurableAgentState
{
SchemaVersion = schemaVersion.ToString(),
Data = data ?? new DurableAgentStateData()
};
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, DurableAgentState value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WritePropertyName(SchemaVersionPropertyName);
writer.WriteStringValue(value.SchemaVersion);
writer.WritePropertyName(DataPropertyName);
JsonSerializer.Serialize(
writer,
value.Data,
DurableAgentStateJsonContext.Default.DurableAgentStateData);
writer.WriteEndObject();
}
}
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents a single message within a durable agent state entry.
/// </summary>
internal sealed class DurableAgentStateMessage
{
/// <summary>
/// Gets the name of the author of this message.
/// </summary>
[JsonPropertyName("authorName")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? AuthorName { get; init; }
/// <summary>
/// Gets the timestamp when this message was created.
/// </summary>
[JsonPropertyName("createdAt")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CreatedAt { get; init; }
/// <summary>
/// Gets the contents of this message.
/// </summary>
[JsonPropertyName("contents")]
public IReadOnlyList<DurableAgentStateContent> Contents { get; init; } = [];
/// <summary>
/// Gets the role of the message sender (e.g., "user", "assistant", "system").
/// </summary>
[JsonPropertyName("role")]
public required string Role { get; init; }
/// <summary>
/// Gets any additional data found during deserialization that does not map to known properties.
/// </summary>
[JsonExtensionData]
public IDictionary<string, JsonElement>? ExtensionData { get; set; }
/// <summary>
/// Creates a <see cref="DurableAgentStateMessage"/> from a <see cref="ChatMessage"/>.
/// </summary>
/// <param name="message">The <see cref="ChatMessage"/> to convert.</param>
/// <returns>A <see cref="DurableAgentStateMessage"/> representing the original message.</returns>
public static DurableAgentStateMessage FromChatMessage(ChatMessage message)
{
return new DurableAgentStateMessage()
{
CreatedAt = message.CreatedAt,
AuthorName = message.AuthorName,
Role = message.Role.ToString(),
Contents = message.Contents.Select(DurableAgentStateContent.FromAIContent).ToList()
};
}
/// <summary>
/// Converts this <see cref="DurableAgentStateMessage"/> to a <see cref="ChatMessage"/>.
/// </summary>
/// <returns>A <see cref="ChatMessage"/> representing this message.</returns>
public ChatMessage ToChatMessage()
{
return new ChatMessage()
{
CreatedAt = this.CreatedAt,
AuthorName = this.AuthorName,
Contents = this.Contents.Select(c => c.ToAIContent()).ToList(),
Role = new(this.Role)
};
}
}
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents a user or system request entry in the durable agent state.
/// </summary>
internal sealed class DurableAgentStateRequest : DurableAgentStateEntry
{
/// <summary>
/// Gets the expected response type for this request (e.g. "json" or "text").
/// </summary>
/// <remarks>
/// If omitted, the expectation is that the agent will respond in plain text.
/// </remarks>
[JsonPropertyName("responseType")]
public string? ResponseType { get; init; }
/// <summary>
/// Gets the expected response JSON schema for this request, if applicable.
/// </summary>
/// <remarks>
/// This is only applicable when <see cref="ResponseType"/> is "json".
/// If omitted, no specific schema is expected.
/// </remarks>
[JsonPropertyName("responseSchema")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonElement? ResponseSchema { get; init; }
/// <summary>
/// Creates a <see cref="DurableAgentStateRequest"/> from a <see cref="RunRequest"/>.
/// </summary>
/// <param name="request">The <see cref="RunRequest"/> to convert.</param>
/// <returns>A <see cref="DurableAgentStateRequest"/> representing the original request.</returns>
public static DurableAgentStateRequest FromRunRequest(RunRequest request)
{
return new DurableAgentStateRequest()
{
CorrelationId = request.CorrelationId,
Messages = request.Messages.Select(DurableAgentStateMessage.FromChatMessage).ToList(),
CreatedAt = request.Messages.Min(m => m.CreatedAt) ?? DateTimeOffset.UtcNow,
ResponseType = request.ResponseFormat is ChatResponseFormatJson ? "json" : "text",
ResponseSchema = (request.ResponseFormat as ChatResponseFormatJson)?.Schema
};
}
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents a durable agent state entry that is a response from the agent.
/// </summary>
internal sealed class DurableAgentStateResponse : DurableAgentStateEntry
{
/// <summary>
/// Gets the usage details for this state response.
/// </summary>
[JsonPropertyName("usage")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DurableAgentStateUsage? Usage { get; init; }
/// <summary>
/// Creates a <see cref="DurableAgentStateResponse"/> from an <see cref="AgentRunResponse"/>.
/// </summary>
/// <param name="correlationId">The correlation ID linking this response to its request.</param>
/// <param name="response">The <see cref="AgentRunResponse"/> to convert.</param>
/// <returns>A <see cref="DurableAgentStateResponse"/> representing the original response.</returns>
public static DurableAgentStateResponse FromRunResponse(string correlationId, AgentRunResponse response)
{
return new DurableAgentStateResponse()
{
CorrelationId = correlationId,
CreatedAt = response.CreatedAt ?? response.Messages.Max(m => m.CreatedAt) ?? DateTimeOffset.UtcNow,
Messages = response.Messages.Select(DurableAgentStateMessage.FromChatMessage).ToList(),
Usage = DurableAgentStateUsage.FromUsage(response.Usage)
};
}
/// <summary>
/// Converts this <see cref="DurableAgentStateResponse"/> back to an <see cref="AgentRunResponse"/>.
/// </summary>
/// <returns>A <see cref="AgentRunResponse"/> representing this response.</returns>
public AgentRunResponse ToRunResponse()
{
return new AgentRunResponse()
{
CreatedAt = this.CreatedAt,
Messages = this.Messages.Select(m => m.ToChatMessage()).ToList(),
Usage = this.Usage?.ToUsageDetails(),
};
}
}
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents the text content for a durable agent state entry.
/// </summary>
internal sealed class DurableAgentStateTextContent : DurableAgentStateContent
{
/// <summary>
/// Gets the text message content.
/// </summary>
[JsonPropertyName("text")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public required string? Text { get; init; }
/// <summary>
/// Creates a <see cref="DurableAgentStateTextContent"/> from a <see cref="TextContent"/>.
/// </summary>
/// <param name="content">The <see cref="TextContent"/> to convert.</param>
/// <returns>A <see cref="DurableAgentStateTextContent"/> representing the original content.</returns>
public static DurableAgentStateTextContent FromTextContent(TextContent content)
{
return new DurableAgentStateTextContent()
{
Text = content.Text
};
}
/// <inheritdoc/>
public override AIContent ToAIContent()
{
return new TextContent(this.Text);
}
}
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents the text reasoning content for a durable agent state entry.
/// </summary>
internal sealed class DurableAgentStateTextReasoningContent : DurableAgentStateContent
{
/// <summary>
/// Gets the text reasoning content.
/// </summary>
[JsonPropertyName("text")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Text { get; init; }
/// <summary>
/// Creates a <see cref="DurableAgentStateTextReasoningContent"/> from a <see cref="TextReasoningContent"/>.
/// </summary>
/// <param name="content">The <see cref="TextReasoningContent"/> to convert.</param>
/// <returns>A <see cref="DurableAgentStateTextReasoningContent"/> representing the original content.</returns>
public static DurableAgentStateTextReasoningContent FromTextReasoningContent(TextReasoningContent content)
{
return new DurableAgentStateTextReasoningContent()
{
Text = content.Text
};
}
/// <inheritdoc/>
public override AIContent ToAIContent()
{
return new TextReasoningContent(this.Text);
}
}
@@ -0,0 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents the unknown content for a durable agent state entry.
/// </summary>
internal sealed class DurableAgentStateUnknownContent : DurableAgentStateContent
{
/// <summary>
/// Gets the serialized unknown content.
/// </summary>
[JsonPropertyName("content")]
public required JsonElement Content { get; init; }
/// <summary>
/// Creates a <see cref="DurableAgentStateUnknownContent"/> from an <see cref="AIContent"/>.
/// </summary>
/// <param name="content">The <see cref="AIContent"/> to convert.</param>
/// <returns>A <see cref="DurableAgentStateUnknownContent"/> representing the original content.</returns>
public static DurableAgentStateUnknownContent FromUnknownContent(AIContent content)
{
return new DurableAgentStateUnknownContent()
{
Content = JsonSerializer.SerializeToElement(
value: content,
jsonTypeInfo: AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent)))
};
}
/// <inheritdoc/>
public override AIContent ToAIContent()
{
AIContent? content = this.Content.Deserialize(
jsonTypeInfo: AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent))) as AIContent;
return content ?? throw new InvalidOperationException($"The content '{this.Content}' is not valid AI content.");
}
}
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents URI content for a durable agent state message.
/// </summary>
internal sealed class DurableAgentStateUriContent : DurableAgentStateContent
{
/// <summary>
/// Gets the URI of the content.
/// </summary>
[JsonPropertyName("uri")]
public required Uri Uri { get; init; }
/// <summary>
/// Gets the media type of the content.
/// </summary>
[JsonPropertyName("mediaType")]
public required string MediaType { get; init; }
/// <summary>
/// Creates a <see cref="DurableAgentStateUriContent"/> from a <see cref="UriContent"/>.
/// </summary>
/// <param name="uriContent">The <see cref="UriContent"/> to convert.</param>
/// <returns>A <see cref="DurableAgentStateUriContent"/> representing the original content.</returns>
public static DurableAgentStateUriContent FromUriContent(UriContent uriContent)
{
return new DurableAgentStateUriContent()
{
MediaType = uriContent.MediaType,
Uri = uriContent.Uri
};
}
/// <inheritdoc/>
public override AIContent ToAIContent()
{
return new UriContent(this.Uri, this.MediaType);
}
}
@@ -0,0 +1,71 @@
// 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.State;
/// <summary>
/// Represents the token usage details for a durable agent state response.
/// </summary>
internal sealed class DurableAgentStateUsage
{
/// <summary>
/// Gets the number of input tokens used.
/// </summary>
[JsonPropertyName("inputTokenCount")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? InputTokenCount { get; init; }
/// <summary>
/// Gets the number of output tokens used.
/// </summary>
[JsonPropertyName("outputTokenCount")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? OutputTokenCount { get; init; }
/// <summary>
/// Gets the total number of tokens used.
/// </summary>
[JsonPropertyName("totalTokenCount")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? TotalTokenCount { get; init; }
/// <summary>
/// Gets any additional data found during deserialization that does not map to known properties.
/// </summary>
[JsonExtensionData]
public IDictionary<string, JsonElement>? ExtensionData { get; set; }
/// <summary>
/// Creates a <see cref="DurableAgentStateUsage"/> from a <see cref="UsageDetails"/>.
/// </summary>
/// <param name="usage">The <see cref="UsageDetails"/> to convert.</param>
/// <returns>A <see cref="DurableAgentStateUsage"/> representing the original usage details.</returns>
[return: NotNullIfNotNull(nameof(usage))]
public static DurableAgentStateUsage? FromUsage(UsageDetails? usage) =>
usage is not null
? new()
{
InputTokenCount = usage.InputTokenCount,
OutputTokenCount = usage.OutputTokenCount,
TotalTokenCount = usage.TotalTokenCount
}
: null;
/// <summary>
/// Converts this <see cref="DurableAgentStateUsage"/> back to a <see cref="UsageDetails"/>.
/// </summary>
/// <returns>A <see cref="UsageDetails"/> representing this usage.</returns>
public UsageDetails ToUsageDetails()
{
return new()
{
InputTokenCount = this.InputTokenCount,
OutputTokenCount = this.OutputTokenCount,
TotalTokenCount = this.TotalTokenCount
};
}
}
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
/// <summary>
/// Represents the content for a durable agent state message.
/// </summary>
internal sealed class DurableAgentStateUsageContent : DurableAgentStateContent
{
/// <summary>
/// Gets the usage details.
/// </summary>
[JsonPropertyName("usage")]
public DurableAgentStateUsage Usage { get; init; } = new();
/// <summary>
/// Creates a <see cref="DurableAgentStateUsageContent"/> from a <see cref="UsageContent"/>.
/// </summary>
/// <param name="content">The <see cref="UsageContent"/> to convert.</param>
/// <returns>A <see cref="DurableAgentStateUsageContent"/> representing the original content.</returns>
public static DurableAgentStateUsageContent FromUsageContent(UsageContent content)
{
return new DurableAgentStateUsageContent()
{
Usage = DurableAgentStateUsage.FromUsage(content.Details)
};
}
/// <inheritdoc/>
public override AIContent ToAIContent()
{
return new UsageContent(this.Usage.ToUsageDetails());
}
}
@@ -0,0 +1,147 @@
# Durable Agent State
Durable agents are represented as durable entities, with each session (i.e. thread) of conversation history stored as JSON-serialized state for an individual entity instance.
## State Schema
The [schema](../../../../schemas/durable-agent-entity-state.json) for durable agent state is a distillation of the prompt and response messages accumulated over the lifetime of a session. While these messages and content originate from Microsoft Agent Framework types (for .NET, see [ChatMessage](https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs) and [AIContent](https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs)), durable agent state uses its own, parallel, types in order to (1) better manage the versioning and compatibility of serialized state over time, (2) account for agent implementations across languages/platforms (e.g. .NET and Python), as well as (3) ensure consistency for external tools that make use of state data.
> When new AI content types are added to the Microsoft Agent Framework, equivalent types should be added to the entity state schema as well. The durable agent state "unknown" type can be used when an AI content type is encountered but no equivalent type exists.
## State Versioning
The serialized state contains a root `schemaVersion` property, which represents the version of the schema used to serialize data in that state (represented by the `data` property).
Some versioning considerations:
- Versions should use semver notation (e.g. `"<major>.<minor>.<patch>"`)
- Durable agents should use the version property to determine how to deserialize that state and should not attempt to deserialize semver-incompatible versions
- Newer versions of durable agents should strive to be compatible with older schema versions (e.g. new properties and objects should be optional)
- Durable agents should preserve existing, but unrecognized, properties when serializing state
## Sample State
```json
{
"schemaVersion": "1.0.0",
"data": {
"conversationHistory": [
{
"$type": "request",
"responseType": "text",
"correlationId": "c338f064f4b44b8d9c21a66e3cda41b2",
"createdAt": "2025-11-04T19:33:05.245476+00:00",
"messages": [
{
"contents": [
{
"$type": "text",
"text": "Start the documentation generation workflow for the product \u0027Goldbrew Coffee\u0027"
}
],
"role": "user"
}
]
},
{
"$type": "response",
"usage": {
"inputTokenCount": 595,
"outputTokenCount": 63,
"totalTokenCount": 658
},
"correlationId": "c338f064f4b44b8d9c21a66e3cda41b2",
"createdAt": "2025-11-04T19:33:10.47008+00:00",
"messages": [
{
"authorName": "OrchestratorAgent",
"createdAt": "2025-11-04T19:33:10+00:00",
"contents": [
{
"$type": "functionCall",
"arguments": {
"productName": "Goldbrew Coffee"
},
"callId": "call_qWk9Ay4doKYrUBoADK8MBwHf",
"name": "StartDocumentGeneration"
}
],
"role": "assistant"
},
{
"authorName": "OrchestratorAgent",
"createdAt": "2025-11-04T19:33:10.47008+00:00",
"contents": [
{
"$type": "functionResult",
"callId": "call_qWk9Ay4doKYrUBoADK8MBwHf",
"result": "8b835e8f2a6f40faabdba33bd8fd8c74"
}
],
"role": "tool"
},
{
"authorName": "OrchestratorAgent",
"createdAt": "2025-11-04T19:33:10+00:00",
"contents": [
{
"$type": "text",
"text": "The documentation generation workflow for the product \u0022Goldbrew Coffee\u0022 has been started. You can request updates on its status or provide additional input anytime during the process. Let me know how you\u2019d like to proceed!"
}
],
"role": "assistant"
}
]
},
{
"$type": "request",
"responseType": "text",
"correlationId": "71f35b7add6b403fadd0db8a7c137b58",
"createdAt": "2025-11-04T19:33:11.903413+00:00",
"messages": [
{
"contents": [
{
"$type": "text",
"text": "Tell the user that you\u0027re starting to gather information for product \u0027Goldbrew Coffee\u0027."
}
],
"role": "system"
}
]
},
{
"$type": "response",
"usage": {
"inputTokenCount": 396,
"outputTokenCount": 48,
"totalTokenCount": 444
},
"correlationId": "71f35b7add6b403fadd0db8a7c137b58",
"createdAt": "2025-11-04T19:33:12+00:00",
"messages": [
{
"authorName": "OrchestratorAgent",
"createdAt": "2025-11-04T19:33:12+00:00",
"contents": [
{
"$type": "text",
"text": "I am starting to gather information to create product documentation for \u0027Goldbrew Coffee\u0027. If you have any specific details, key features, or requirements you\u0027d like included, please share them. Otherwise, I\u0027ll continue with the standard documentation process."
}
],
"role": "assistant"
}
]
}
]
}
}
```
## State Consumers
Additional tools may make use of durable agent state. Significant changes to the state schema may need corresponding changes to those applications.
### Durable Task Scheduler Dashboard
The [Durable Task Scheduler (DTS)](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler) Dashboard, while providing general UX for management of durable orchestrations and entities, also has UX specific to the use of durable agents.
@@ -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,110 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Context.Features;
using Microsoft.Azure.Functions.Worker.Extensions.Mcp;
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;
ToolInvocationContext? mcpToolInvocationContext = 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;
case ToolInvocationContext toolContext:
mcpToolInvocationContext = toolContext;
break;
}
}
if (durableTaskClient is null)
{
// This is not expected to happen since all built-in functions are
// expected to have a Durable Task client binding.
throw new InvalidOperationException($"Durable Task client binding is missing for the invocation {context.InvocationId}.");
}
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunAgentHttpFunctionEntryPoint)
{
if (httpRequestData == null)
{
throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}.");
}
context.GetInvocationResult().Value = await BuiltInFunctions.RunAgentHttpAsync(
httpRequestData,
durableTaskClient,
context);
return;
}
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunAgentEntityFunctionEntryPoint)
{
if (dispatcher is null)
{
throw new InvalidOperationException($"Task entity dispatcher binding is missing for the invocation {context.InvocationId}.");
}
await BuiltInFunctions.InvokeAgentAsync(
dispatcher,
durableTaskClient,
context);
return;
}
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint)
{
if (mcpToolInvocationContext is null)
{
throw new InvalidOperationException($"MCP tool invocation context binding is missing for the invocation {context.InvocationId}.");
}
context.GetInvocationResult().Value =
await BuiltInFunctions.RunMcpToolAsync(mcpToolInvocationContext, durableTaskClient, context);
return;
}
throw new InvalidOperationException($"Unsupported function entry point '{context.FunctionDefinition.EntryPoint}' for invocation {context.InvocationId}.");
}
}
@@ -0,0 +1,373 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Extensions.Mcp;
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 const string HttpPrefix = "http-";
internal const string McpToolPrefix = "mcptool-";
internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}";
internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}";
internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}";
// 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)
{
// Parse request body - support both JSON and plain text
string? message = null;
string? threadIdFromBody = null;
if (req.Headers.TryGetValues("Content-Type", out IEnumerable<string>? contentTypeValues) &&
contentTypeValues.Any(ct => ct.Contains("application/json", StringComparison.OrdinalIgnoreCase)))
{
// Parse JSON body using POCO record
AgentRunRequest? requestBody = await req.ReadFromJsonAsync<AgentRunRequest>(context.CancellationToken);
if (requestBody != null)
{
message = requestBody.Message;
threadIdFromBody = requestBody.ThreadId;
}
}
else
{
// Plain text body
message = await req.ReadAsStringAsync();
}
// The thread ID can come from query string or JSON body
string? threadIdFromQuery = req.Query["thread_id"];
// Validate that if thread_id is specified in both places, they must match
if (!string.IsNullOrEmpty(threadIdFromQuery) && !string.IsNullOrEmpty(threadIdFromBody) &&
!string.Equals(threadIdFromQuery, threadIdFromBody, StringComparison.Ordinal))
{
return await CreateErrorResponseAsync(
req,
context,
HttpStatusCode.BadRequest,
"thread_id specified in both query string and request body must match.");
}
string? threadIdValue = threadIdFromBody ?? threadIdFromQuery;
// 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(threadIdValue)
? new AgentSessionId(GetAgentName(context), context.InvocationId)
: AgentSessionId.Parse(threadIdValue);
if (string.IsNullOrWhiteSpace(message))
{
return await CreateErrorResponseAsync(
req,
context,
HttpStatusCode.BadRequest,
"Run request cannot be empty.");
}
// Check if we should wait for response (default is true)
bool waitForResponse = true;
if (req.Headers.TryGetValues("x-ms-wait-for-response", out IEnumerable<string>? waitForResponseValues))
{
string? waitForResponseValue = waitForResponseValues.FirstOrDefault();
if (!string.IsNullOrEmpty(waitForResponseValue) && bool.TryParse(waitForResponseValue, out bool parsedValue))
{
waitForResponse = parsedValue;
}
}
AIAgent agentProxy = client.AsDurableAgentProxy(context, GetAgentName(context));
DurableAgentRunOptions options = new() { IsFireAndForget = !waitForResponse };
if (waitForResponse)
{
AgentRunResponse agentResponse = await agentProxy.RunAsync(
message: new ChatMessage(ChatRole.User, message),
thread: new DurableAgentThread(sessionId),
options: options,
cancellationToken: context.CancellationToken);
return await CreateSuccessResponseAsync(
req,
context,
HttpStatusCode.OK,
sessionId.ToString(),
agentResponse);
}
// Fire and forget - return 202 Accepted
await agentProxy.RunAsync(
message: new ChatMessage(ChatRole.User, message),
thread: new DurableAgentThread(sessionId),
options: options,
cancellationToken: context.CancellationToken);
return await CreateAcceptedResponseAsync(
req,
context,
sessionId.ToString());
}
public static async Task<string?> RunMcpToolAsync(
[McpToolTrigger("BuiltInMcpTool")] ToolInvocationContext context,
[DurableClient] DurableTaskClient client,
FunctionContext functionContext)
{
if (context.Arguments is null)
{
throw new ArgumentException("MCP Tool invocation is missing required arguments.");
}
if (!context.Arguments.TryGetValue("query", out object? queryObj) || queryObj is not string query)
{
throw new ArgumentException("MCP Tool invocation is missing required 'query' argument of type string.");
}
string agentName = context.Name;
// Derive session id: try to parse provided threadId, otherwise create a new one.
AgentSessionId sessionId = context.Arguments.TryGetValue("threadId", out object? threadObj) && threadObj is string threadId && !string.IsNullOrWhiteSpace(threadId)
? AgentSessionId.Parse(threadId)
: new AgentSessionId(agentName, functionContext.InvocationId);
AIAgent agentProxy = client.AsDurableAgentProxy(functionContext, agentName);
AgentRunResponse agentResponse = await agentProxy.RunAsync(
message: new ChatMessage(ChatRole.User, query),
thread: new DurableAgentThread(sessionId),
options: null);
return agentResponse.Text;
}
/// <summary>
/// Creates an error response with the specified status code and error message.
/// </summary>
/// <param name="req">The HTTP request data.</param>
/// <param name="context">The function context.</param>
/// <param name="statusCode">The HTTP status code.</param>
/// <param name="errorMessage">The error message.</param>
/// <returns>The HTTP response data containing the error.</returns>
private static async Task<HttpResponseData> CreateErrorResponseAsync(
HttpRequestData req,
FunctionContext context,
HttpStatusCode statusCode,
string errorMessage)
{
HttpResponseData response = req.CreateResponse(statusCode);
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
if (acceptsJson)
{
ErrorResponse errorResponse = new((int)statusCode, errorMessage);
await response.WriteAsJsonAsync(errorResponse, context.CancellationToken);
}
else
{
response.Headers.Add("Content-Type", "text/plain");
await response.WriteStringAsync(errorMessage, context.CancellationToken);
}
return response;
}
/// <summary>
/// Creates a successful agent run response with the agent's response.
/// </summary>
/// <param name="req">The HTTP request data.</param>
/// <param name="context">The function context.</param>
/// <param name="statusCode">The HTTP status code (typically 200 OK).</param>
/// <param name="threadId">The thread ID for the conversation.</param>
/// <param name="agentResponse">The agent's response.</param>
/// <returns>The HTTP response data containing the success response.</returns>
private static async Task<HttpResponseData> CreateSuccessResponseAsync(
HttpRequestData req,
FunctionContext context,
HttpStatusCode statusCode,
string threadId,
AgentRunResponse agentResponse)
{
HttpResponseData response = req.CreateResponse(statusCode);
response.Headers.Add("x-ms-thread-id", threadId);
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
if (acceptsJson)
{
AgentRunSuccessResponse successResponse = new((int)statusCode, threadId, agentResponse);
await response.WriteAsJsonAsync(successResponse, context.CancellationToken);
}
else
{
response.Headers.Add("Content-Type", "text/plain");
await response.WriteStringAsync(agentResponse.Text, context.CancellationToken);
}
return response;
}
/// <summary>
/// Creates an accepted (fire-and-forget) agent run response.
/// </summary>
/// <param name="req">The HTTP request data.</param>
/// <param name="context">The function context.</param>
/// <param name="threadId">The thread ID for the conversation.</param>
/// <returns>The HTTP response data containing the accepted response.</returns>
private static async Task<HttpResponseData> CreateAcceptedResponseAsync(
HttpRequestData req,
FunctionContext context,
string threadId)
{
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
response.Headers.Add("x-ms-thread-id", threadId);
bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable<string>? acceptValues) &&
acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase);
if (acceptsJson)
{
AgentRunAcceptedResponse acceptedResponse = new((int)HttpStatusCode.Accepted, threadId);
await response.WriteAsJsonAsync(acceptedResponse, context.CancellationToken);
}
else
{
response.Headers.Add("Content-Type", "text/plain");
await response.WriteStringAsync("Request accepted.", context.CancellationToken);
}
return response;
}
private static string GetAgentName(FunctionContext context)
{
// Check if the function name starts with the HttpPrefix
string functionName = context.FunctionDefinition.Name;
if (!functionName.StartsWith(HttpPrefix, StringComparison.Ordinal))
{
// This should never happen because the function metadata provider ensures
// that the function name starts with the HttpPrefix (http-).
throw new InvalidOperationException(
$"Built-in HTTP trigger function name '{functionName}' does not start with '{HttpPrefix}'.");
}
// Remove the HttpPrefix from the function name to get the agent name.
return functionName[HttpPrefix.Length..];
}
/// <summary>
/// Represents a request to run an agent.
/// </summary>
/// <param name="Message">The message to send to the agent.</param>
/// <param name="ThreadId">The optional thread ID to continue a conversation.</param>
private sealed record AgentRunRequest(
[property: JsonPropertyName("message")] string? Message,
[property: JsonPropertyName("thread_id")] string? ThreadId);
/// <summary>
/// Represents an error response.
/// </summary>
/// <param name="Status">The HTTP status code.</param>
/// <param name="Error">The error message.</param>
private sealed record ErrorResponse(
[property: JsonPropertyName("status")] int Status,
[property: JsonPropertyName("error")] string Error);
/// <summary>
/// Represents a successful agent run response.
/// </summary>
/// <param name="Status">The HTTP status code.</param>
/// <param name="ThreadId">The thread ID for the conversation.</param>
/// <param name="Response">The agent response.</param>
private sealed record AgentRunSuccessResponse(
[property: JsonPropertyName("status")] int Status,
[property: JsonPropertyName("thread_id")] string ThreadId,
[property: JsonPropertyName("response")] AgentRunResponse Response);
/// <summary>
/// Represents an accepted (fire-and-forget) agent run response.
/// </summary>
/// <param name="Status">The HTTP status code.</param>
/// <param name="ThreadId">The thread ID for the conversation.</param>
private sealed record AgentRunAcceptedResponse(
[property: JsonPropertyName("status")] int Status,
[property: JsonPropertyName("thread_id")] string ThreadId);
/// <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.
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Provides access to agent-specific options for functions agents by name.
/// Returns default options (HTTP trigger enabled, MCP tool disabled) when no explicit options were configured.
/// </summary>
internal sealed class DefaultFunctionsAgentOptionsProvider(IReadOnlyDictionary<string, FunctionsAgentOptions> functionsAgentOptions)
: IFunctionsAgentOptionsProvider
{
private readonly IReadOnlyDictionary<string, FunctionsAgentOptions> _functionsAgentOptions =
functionsAgentOptions ?? throw new ArgumentNullException(nameof(functionsAgentOptions));
// Default options. HTTP trigger enabled, MCP tool disabled.
private static readonly FunctionsAgentOptions s_defaultOptions = new()
{
HttpTrigger = { IsEnabled = true },
McpToolTrigger = { IsEnabled = false }
};
/// <summary>
/// Attempts to retrieve the options associated with the specified agent name.
/// If not found, a default options instance (with HTTP trigger enabled) is returned.
/// </summary>
/// <param name="agentName">The name of the agent whose options are to be retrieved. Cannot be null or empty.</param>
/// <param name="options">The options for the specified agent. Will never be null.</param>
/// <returns>Always true. Returns configured options if present; otherwise default fallback options.</returns>
public bool TryGet(string agentName, [NotNullWhen(true)] out FunctionsAgentOptions? options)
{
ArgumentException.ThrowIfNullOrEmpty(agentName);
if (this._functionsAgentOptions.TryGetValue(agentName, out FunctionsAgentOptions? existing))
{
options = existing;
return true;
}
// If not defined, return default options.
options = s_defaultOptions;
return true;
}
}
@@ -0,0 +1,118 @@
// 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;
private readonly IServiceProvider _serviceProvider;
private readonly IFunctionsAgentOptionsProvider _functionsAgentOptionsProvider;
#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,
IServiceProvider serviceProvider,
IFunctionsAgentOptionsProvider functionsAgentOptionsProvider)
{
this._agents = agents ?? throw new ArgumentNullException(nameof(agents));
this._logger = logger ?? throw new ArgumentNullException(nameof(logger));
this._serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
this._functionsAgentOptionsProvider = functionsAgentOptionsProvider ?? throw new ArgumentNullException(nameof(functionsAgentOptionsProvider));
}
public string Name => nameof(DurableAgentFunctionMetadataTransformer);
public void Transform(IList<IFunctionMetadata> original)
{
this._logger.LogTransformingFunctionMetadata(original.Count);
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> kvp in this._agents)
{
string agentName = kvp.Key;
this._logger.LogRegisteringTriggerForAgent(agentName, "entity");
original.Add(CreateAgentTrigger(agentName));
if (this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions))
{
if (agentTriggerOptions.HttpTrigger.IsEnabled)
{
this._logger.LogRegisteringTriggerForAgent(agentName, "http");
original.Add(CreateHttpTrigger(agentName, $"agents/{agentName}/run"));
}
if (agentTriggerOptions.McpToolTrigger.IsEnabled)
{
AIAgent agent = kvp.Value(this._serviceProvider);
this._logger.LogRegisteringTriggerForAgent(agentName, "mcpTool");
original.Add(CreateMcpToolTrigger(agentName, agent.Description));
}
}
}
}
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)
{
return new DefaultFunctionMetadata()
{
Name = $"{BuiltInFunctions.HttpPrefix}{name}",
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,
};
}
private static DefaultFunctionMetadata CreateMcpToolTrigger(string agentName, string? description)
{
return new DefaultFunctionMetadata
{
Name = $"{BuiltInFunctions.McpToolPrefix}{agentName}",
Language = "dotnet-isolated",
RawBindings =
[
$$"""{"name":"context","type":"mcpToolTrigger","direction":"In","toolName":"{{agentName}}","description":"{{description}}","toolProperties":"[{\"propertyName\":\"query\",\"propertyType\":\"string\",\"description\":\"The query to send to the agent.\",\"isRequired\":true,\"isArray\":false},{\"propertyName\":\"threadId\",\"propertyType\":\"string\",\"description\":\"Optional thread identifier.\",\"isRequired\":false,\"isArray\":false}]"}""",
"""{"name":"query","type":"mcpToolProperty","direction":"In","propertyName":"query","description":"The query to send to the agent","isRequired":true,"dataType":"String","propertyType":"string"}""",
"""{"name":"threadId","type":"mcpToolProperty","direction":"In","propertyName":"threadId","description":"The thread identifier.","isRequired":false,"dataType":"String","propertyType":"string"}""",
"""{"name":"client","type":"durableClient","direction":"In"}"""
],
EntryPoint = BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint,
ScriptFile = s_builtInFunctionsScriptFile,
};
}
}
@@ -0,0 +1,137 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Provides extension methods for registering and configuring AI agents in the context of the Azure Functions hosting environment.
/// </summary>
public static class DurableAgentsOptionsExtensions
{
// Registry of agent options.
private static readonly Dictionary<string, FunctionsAgentOptions> s_agentOptions = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Adds an AI agent to the specified DurableAgentsOptions instance and optionally configures agent-specific
/// options.
/// </summary>
/// <param name="options">The DurableAgentsOptions instance to which the AI agent will be added.</param>
/// <param name="agent">The AI agent to add. The agent's Name property must not be null or empty.</param>
/// <param name="configure">An optional delegate to configure agent-specific options. If null, default options are used.</param>
/// <returns>The updated <see cref="DurableAgentsOptions"/> instance containing the added AI agent.</returns>
public static DurableAgentsOptions AddAIAgent(
this DurableAgentsOptions options,
AIAgent agent,
Action<FunctionsAgentOptions>? configure)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrEmpty(agent.Name);
// Initialize with default behavior (HTTP trigger enabled)
FunctionsAgentOptions agentOptions = new() { HttpTrigger = { IsEnabled = true } };
configure?.Invoke(agentOptions);
options.AddAIAgent(agent);
s_agentOptions[agent.Name] = agentOptions;
return options;
}
/// <summary>
/// Adds an AI agent to the specified options and configures trigger support for HTTP and MCP tool invocations.
/// </summary>
/// <remarks>If an agent with the same name already exists in the options, its configuration will be
/// updated. Both triggers can be enabled independently. This method supports method chaining by returning the
/// provided options instance.</remarks>
/// <param name="options">The options collection to which the AI agent will be added. Cannot be null.</param>
/// <param name="agent">The AI agent to add. The agent's Name property must not be null or empty.</param>
/// <param name="enableHttpTrigger">true to enable an HTTP trigger for the agent; otherwise, false.</param>
/// <param name="enableMcpToolTrigger">true to enable an MCP tool trigger for the agent; otherwise, false.</param>
/// <returns>The updated <see cref="DurableAgentsOptions"/> instance with the specified AI agent and trigger configuration applied.</returns>
public static DurableAgentsOptions AddAIAgent(
this DurableAgentsOptions options,
AIAgent agent,
bool enableHttpTrigger,
bool enableMcpToolTrigger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrEmpty(agent.Name);
FunctionsAgentOptions agentOptions = new();
agentOptions.HttpTrigger.IsEnabled = enableHttpTrigger;
agentOptions.McpToolTrigger.IsEnabled = enableMcpToolTrigger;
options.AddAIAgent(agent);
s_agentOptions[agent.Name] = agentOptions;
return options;
}
/// <summary>
/// Registers an AI agent factory with the specified name and optional configuration in the provided
/// DurableAgentsOptions instance.
/// </summary>
/// <remarks>If an agent factory with the same name already exists, its configuration will be replaced.
/// This method enables custom agent registration and configuration for use in durable agent scenarios.</remarks>
/// <param name="options">The DurableAgentsOptions instance to which the AI agent factory will be added. Cannot be null.</param>
/// <param name="name">The unique name used to identify the AI agent factory. Cannot be null.</param>
/// <param name="factory">A delegate that creates an AIAgent instance using the provided IServiceProvider. Cannot be null.</param>
/// <param name="configure">An optional action to configure FunctionsAgentOptions for the agent factory. If null, default options are used.</param>
/// <returns>The updated DurableAgentsOptions instance containing the registered AI agent factory.</returns>
public static DurableAgentsOptions AddAIAgentFactory(
this DurableAgentsOptions options,
string name,
Func<IServiceProvider, AIAgent> factory,
Action<FunctionsAgentOptions>? configure)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(factory);
// Initialize with default behavior (HTTP trigger enabled)
FunctionsAgentOptions agentOptions = new() { HttpTrigger = { IsEnabled = true } };
configure?.Invoke(agentOptions);
options.AddAIAgentFactory(name, factory);
s_agentOptions[name] = agentOptions;
return options;
}
/// <summary>
/// Registers an AI agent factory with the specified name and configures trigger options for the agent.
/// </summary>
/// <remarks>If both triggers are disabled, the agent will not be accessible via HTTP or MCP tool
/// endpoints. This method can be used to register multiple agent factories with different configurations.</remarks>
/// <param name="options">The options object to which the AI agent factory will be added. Cannot be null.</param>
/// <param name="name">The unique name used to identify the AI agent factory. Cannot be null.</param>
/// <param name="factory">A delegate that creates an instance of the AI agent using the provided service provider. Cannot be null.</param>
/// <param name="enableHttpTrigger">true to enable the HTTP trigger for the agent; otherwise, false.</param>
/// <param name="enableMcpToolTrigger">true to enable the MCP tool trigger for the agent; otherwise, false.</param>
/// <returns>The same DurableAgentsOptions instance, allowing for method chaining.</returns>
public static DurableAgentsOptions AddAIAgentFactory(
this DurableAgentsOptions options,
string name,
Func<IServiceProvider, AIAgent> factory,
bool enableHttpTrigger,
bool enableMcpToolTrigger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(factory);
FunctionsAgentOptions agentOptions = new();
agentOptions.HttpTrigger.IsEnabled = enableHttpTrigger;
agentOptions.McpToolTrigger.IsEnabled = enableMcpToolTrigger;
options.AddAIAgentFactory(name, factory);
s_agentOptions[name] = agentOptions;
return options;
}
/// <summary>
/// Builds the agentOptions used for dependency injection (read-only copy).
/// </summary>
internal static IReadOnlyDictionary<string, FunctionsAgentOptions> GetAgentOptionsSnapshot()
{
return new Dictionary<string, FunctionsAgentOptions>(s_agentOptions, StringComparer.OrdinalIgnoreCase);
}
}
@@ -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);
}
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Provides configuration options for enabling and customizing function triggers for an agent.
/// </summary>
public sealed class FunctionsAgentOptions
{
/// <summary>
/// Gets or sets the configuration options for the HTTP trigger endpoint.
/// </summary>
public HttpTriggerOptions HttpTrigger { get; set; } = new(false);
/// <summary>
/// Gets or sets the options used to configure the MCP tool trigger behavior.
/// </summary>
public McpToolTriggerOptions McpToolTrigger { get; set; } = new(false);
}
@@ -0,0 +1,46 @@
// 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<IFunctionsAgentOptionsProvider>(_ =>
new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot()));
builder.Services.AddSingleton<IFunctionMetadataTransformer, DurableAgentFunctionMetadataTransformer>();
// Handling of built-in function execution for Agent HTTP, MCP tool, or Entity invocations.
builder.UseWhen<BuiltInFunctionExecutionMiddleware>(static context =>
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal));
builder.Services.AddSingleton<BuiltInFunctionExecutor>();
return builder;
}
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Represents configuration options for the HTTP trigger for an agent.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="HttpTriggerOptions"/> class.
/// </remarks>
/// <param name="isEnabled">Indicates whether the HTTP trigger is enabled for the agent.</param>
public sealed class HttpTriggerOptions(bool isEnabled)
{
/// <summary>
/// Gets or sets a value indicating whether the HTTP trigger is enabled for the agent.
/// </summary>
public bool IsEnabled { get; set; } = isEnabled;
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Provides access to function trigger options for agents in the Azure Functions hosting environment.
/// </summary>
internal interface IFunctionsAgentOptionsProvider
{
/// <summary>
/// Attempts to get trigger options for the specified agent.
/// </summary>
/// <param name="agentName">The agent name.</param>
/// <param name="options">The resulting options if found.</param>
/// <returns>True if options exist; otherwise false.</returns>
bool TryGet(string agentName, [NotNullWhen(true)] out FunctionsAgentOptions? options);
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
internal static partial class Logs
{
[LoggerMessage(
EventId = 100,
Level = LogLevel.Information,
Message = "Transforming function metadata to add durable agent functions. Initial function count: {FunctionCount}")]
public static partial void LogTransformingFunctionMetadata(this ILogger logger, int functionCount);
[LoggerMessage(
EventId = 101,
Level = LogLevel.Information,
Message = "Registering {TriggerType} function for agent '{AgentName}'")]
public static partial void LogRegisteringTriggerForAgent(this ILogger logger, string agentName, string triggerType);
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// This class provides configuration options for the MCP tool trigger for an agent.
/// </summary>
/// <param name="isEnabled">
/// A value indicating whether the MCP tool trigger is enabled for the agent.
/// Set to <see langword="true"/> to enable the trigger; otherwise, <see langword="false"/>.
/// </param>
public sealed class McpToolTriggerOptions(bool isEnabled)
{
/// <summary>
/// Gets or sets a value indicating whether MCP tool trigger is enabled for the agent.
/// </summary>
public bool IsEnabled { get; set; } = isEnabled;
}
@@ -0,0 +1,59 @@
<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>
</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" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Mcp" />
</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>
<ItemGroup>
<!--
This attribute tells the Functions build process to restore the specified WebJobs extension package,
making the MCP extension available to the Functions host.
-->
<AssemblyAttribute Include="Microsoft.Azure.Functions.Worker.Extensions.Abstractions.ExtensionInformationAttribute">
<_Parameter1>Microsoft.Azure.Functions.Extensions.Mcp</_Parameter1>
<_Parameter2>1.0.0</_Parameter2>
<!--
Force Azure Functions host to load the MCP extension automatically, even when
the consuming application doesn't explicitly reference McpToolTrigger attributes
-->
<_Parameter3>true</_Parameter3>
<_Parameter3_IsLiteral>true</_Parameter3_IsLiteral>
</AssemblyAttribute>
</ItemGroup>
</Project>
@@ -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` 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-storage-providers)
- 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/) in the [Microsoft Agent Framework GitHub 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).