mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add TTLs to durable agent sessions (#2679)
* .NET: Add TTLs to durable agent sessions * Remove unnecessary async * PR feedback: clarify UTC * PR feedback: limit minimum signal delay to <= 5 minutes * PR feedback: Fix TTL disablement * Linter: use auto-property * Fix build break from OpenAI SDK change * Updated CHANGELOG.md * PR feedback * Reduce default TTL to 14 days to work around DTS bug
This commit is contained in:
@@ -16,6 +16,7 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
|
||||
private readonly DurableTaskClient _client = services.GetRequiredService<DurableTaskClient>();
|
||||
private readonly ILoggerFactory _loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
||||
private readonly IAgentResponseHandler? _messageHandler = services.GetService<IAgentResponseHandler>();
|
||||
private readonly DurableAgentsOptions _options = services.GetRequiredService<DurableAgentsOptions>();
|
||||
private readonly CancellationToken _cancellationToken = cancellationToken != default
|
||||
? cancellationToken
|
||||
: services.GetService<IHostApplicationLifetime>()?.ApplicationStopping ?? CancellationToken.None;
|
||||
@@ -23,22 +24,16 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
|
||||
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);
|
||||
AIAgent agent = this.GetAgent(sessionId);
|
||||
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}");
|
||||
ILogger logger = this.GetLogger(agent.Name!, sessionId.Key);
|
||||
|
||||
if (request.Messages.Count == 0)
|
||||
{
|
||||
logger.LogInformation("Ignoring empty request");
|
||||
return new AgentRunResponse();
|
||||
}
|
||||
|
||||
this.State.Data.ConversationHistory.Add(DurableAgentStateRequest.FromRunRequest(request));
|
||||
@@ -113,6 +108,36 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
|
||||
response.Usage?.TotalTokenCount);
|
||||
}
|
||||
|
||||
// Update TTL expiration time. Only schedule deletion check on first interaction.
|
||||
// Subsequent interactions just update the expiration time; CheckAndDeleteIfExpiredAsync
|
||||
// will reschedule the deletion check when it runs.
|
||||
TimeSpan? timeToLive = this._options.GetTimeToLive(sessionId.Name);
|
||||
if (timeToLive.HasValue)
|
||||
{
|
||||
DateTime newExpirationTime = DateTime.UtcNow.Add(timeToLive.Value);
|
||||
bool isFirstInteraction = this.State.Data.ExpirationTimeUtc is null;
|
||||
|
||||
this.State.Data.ExpirationTimeUtc = newExpirationTime;
|
||||
logger.LogTTLExpirationTimeUpdated(sessionId, newExpirationTime);
|
||||
|
||||
// Only schedule deletion check on the first interaction when entity is created.
|
||||
// On subsequent interactions, we just update the expiration time. The scheduled
|
||||
// CheckAndDeleteIfExpiredAsync will reschedule itself if the entity hasn't expired.
|
||||
if (isFirstInteraction)
|
||||
{
|
||||
this.ScheduleDeletionCheck(sessionId, logger, timeToLive.Value);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// TTL is disabled. Clear the expiration time if it was previously set.
|
||||
if (this.State.Data.ExpirationTimeUtc.HasValue)
|
||||
{
|
||||
logger.LogTTLExpirationTimeCleared(sessionId);
|
||||
this.State.Data.ExpirationTimeUtc = null;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
finally
|
||||
@@ -121,4 +146,78 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
|
||||
DurableAgentContext.ClearCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the entity has expired and deletes it if so, otherwise reschedules the deletion check.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method is called by the durable task runtime when a <c>CheckAndDeleteIfExpired</c> signal is received.
|
||||
/// </remarks>
|
||||
public void CheckAndDeleteIfExpired()
|
||||
{
|
||||
AgentSessionId sessionId = this.Context.Id;
|
||||
AIAgent agent = this.GetAgent(sessionId);
|
||||
ILogger logger = this.GetLogger(agent.Name!, sessionId.Key);
|
||||
|
||||
DateTime currentTime = DateTime.UtcNow;
|
||||
DateTime? expirationTime = this.State.Data.ExpirationTimeUtc;
|
||||
|
||||
logger.LogTTLDeletionCheck(sessionId, expirationTime, currentTime);
|
||||
|
||||
if (expirationTime.HasValue)
|
||||
{
|
||||
if (currentTime >= expirationTime.Value)
|
||||
{
|
||||
// Entity has expired, delete it
|
||||
logger.LogTTLEntityExpired(sessionId, expirationTime.Value);
|
||||
this.State = null!;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Entity hasn't expired yet, reschedule the deletion check
|
||||
TimeSpan? timeToLive = this._options.GetTimeToLive(sessionId.Name);
|
||||
if (timeToLive.HasValue)
|
||||
{
|
||||
this.ScheduleDeletionCheck(sessionId, logger, timeToLive.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ScheduleDeletionCheck(AgentSessionId sessionId, ILogger logger, TimeSpan timeToLive)
|
||||
{
|
||||
DateTime currentTime = DateTime.UtcNow;
|
||||
DateTime expirationTime = this.State.Data.ExpirationTimeUtc ?? currentTime.Add(timeToLive);
|
||||
TimeSpan minimumDelay = this._options.MinimumTimeToLiveSignalDelay;
|
||||
|
||||
// To avoid excessive scheduling, we schedule the deletion check for no less than the minimum delay.
|
||||
DateTime scheduledTime = expirationTime > currentTime.Add(minimumDelay)
|
||||
? expirationTime
|
||||
: currentTime.Add(minimumDelay);
|
||||
|
||||
logger.LogTTLDeletionScheduled(sessionId, scheduledTime);
|
||||
|
||||
// Schedule a signal to self to check for expiration
|
||||
this.Context.SignalEntity(
|
||||
this.Context.Id,
|
||||
nameof(CheckAndDeleteIfExpired), // self-signal
|
||||
options: new SignalEntityOptions { SignalTime = scheduledTime });
|
||||
}
|
||||
|
||||
private AIAgent GetAgent(AgentSessionId sessionId)
|
||||
{
|
||||
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");
|
||||
}
|
||||
|
||||
return agentFactory(this._services);
|
||||
}
|
||||
|
||||
private ILogger GetLogger(string agentName, string sessionKey)
|
||||
{
|
||||
return this._loggerFactory.CreateLogger($"Microsoft.DurableTask.Agents.{agentName}.{sessionKey}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Release History
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Added TTL configuration for durable agent entities ([#2679](https://github.com/microsoft/agent-framework/pull/2679))
|
||||
|
||||
## v1.0.0-preview.251204.1
|
||||
|
||||
- Added orchestration ID to durable agent entity state ([#2137](https://github.com/microsoft/agent-framework/pull/2137))
|
||||
|
||||
@@ -9,23 +9,67 @@ public sealed class DurableAgentsOptions
|
||||
{
|
||||
// Agent names are case-insensitive
|
||||
private readonly Dictionary<string, Func<IServiceProvider, AIAgent>> _agentFactories = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, TimeSpan?> _agentTimeToLive = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
internal DurableAgentsOptions()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default time-to-live (TTL) for agent entities.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If an agent entity is idle for this duration, it will be automatically deleted.
|
||||
/// Defaults to 14 days. Set to <see langword="null"/> to disable TTL for agents without explicit TTL configuration.
|
||||
/// </remarks>
|
||||
public TimeSpan? DefaultTimeToLive { get; set; } = TimeSpan.FromDays(14);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum delay for scheduling TTL deletion signals. Defaults to 5 minutes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This property is primarily useful for testing (where shorter delays are needed) or for
|
||||
/// shorter-lived agents in workflows that need more rapid cleanup. The maximum allowed value is 5 minutes.
|
||||
/// Reducing the minimum deletion delay below 5 minutes can be useful for testing or for ensuring rapid cleanup of short-lived agent sessions.
|
||||
/// However, this can also increase the load on the system and should be used with caution.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when the value exceeds 5 minutes.</exception>
|
||||
public TimeSpan MinimumTimeToLiveSignalDelay
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
const int MaximumDelayMinutes = 5;
|
||||
if (value > TimeSpan.FromMinutes(MaximumDelayMinutes))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(value),
|
||||
value,
|
||||
$"The minimum time-to-live signal delay cannot exceed {MaximumDelayMinutes} minutes.");
|
||||
}
|
||||
|
||||
field = value;
|
||||
}
|
||||
} = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <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>
|
||||
/// <param name="timeToLive">Optional time-to-live for this agent's entities. If not specified, uses <see cref="DefaultTimeToLive"/>.</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)
|
||||
public DurableAgentsOptions AddAIAgentFactory(string name, Func<IServiceProvider, AIAgent> factory, TimeSpan? timeToLive = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
this._agentFactories.Add(name, factory);
|
||||
if (timeToLive.HasValue)
|
||||
{
|
||||
this._agentTimeToLive[name] = timeToLive;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -50,12 +94,13 @@ public sealed class DurableAgentsOptions
|
||||
/// Adds an AI agent to the options.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent to add.</param>
|
||||
/// <param name="timeToLive">Optional time-to-live for this agent's entities. If not specified, uses <see cref="DefaultTimeToLive"/>.</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)
|
||||
public DurableAgentsOptions AddAIAgent(AIAgent agent, TimeSpan? timeToLive = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
@@ -70,6 +115,11 @@ public sealed class DurableAgentsOptions
|
||||
}
|
||||
|
||||
this._agentFactories.Add(agent.Name, sp => agent);
|
||||
if (timeToLive.HasValue)
|
||||
{
|
||||
this._agentTimeToLive[agent.Name] = timeToLive;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -81,4 +131,14 @@ public sealed class DurableAgentsOptions
|
||||
{
|
||||
return this._agentFactories.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the time-to-live for a specific agent, or the default TTL if not specified.
|
||||
/// </summary>
|
||||
/// <param name="agentName">The name of the agent.</param>
|
||||
/// <returns>The time-to-live for the agent, or the default TTL if not specified.</returns>
|
||||
internal TimeSpan? GetTimeToLive(string agentName)
|
||||
{
|
||||
return this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,4 +46,58 @@ internal static partial class Logs
|
||||
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);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 6,
|
||||
Level = LogLevel.Information,
|
||||
Message = "[{SessionId}] TTL expiration time updated to {ExpirationTime:O}")]
|
||||
public static partial void LogTTLExpirationTimeUpdated(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId,
|
||||
DateTime expirationTime);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 7,
|
||||
Level = LogLevel.Information,
|
||||
Message = "[{SessionId}] TTL deletion signal scheduled for {ScheduledTime:O}")]
|
||||
public static partial void LogTTLDeletionScheduled(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId,
|
||||
DateTime scheduledTime);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 8,
|
||||
Level = LogLevel.Information,
|
||||
Message = "[{SessionId}] TTL deletion check running. Expiration time: {ExpirationTime:O}, Current time: {CurrentTime:O}")]
|
||||
public static partial void LogTTLDeletionCheck(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId,
|
||||
DateTime? expirationTime,
|
||||
DateTime currentTime);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 9,
|
||||
Level = LogLevel.Information,
|
||||
Message = "[{SessionId}] Entity expired and deleted due to TTL. Expiration time: {ExpirationTime:O}")]
|
||||
public static partial void LogTTLEntityExpired(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId,
|
||||
DateTime expirationTime);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 10,
|
||||
Level = LogLevel.Information,
|
||||
Message = "[{SessionId}] TTL deletion signal rescheduled for {ScheduledTime:O}")]
|
||||
public static partial void LogTTLRescheduled(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId,
|
||||
DateTime scheduledTime);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 11,
|
||||
Level = LogLevel.Information,
|
||||
Message = "[{SessionId}] TTL expiration time cleared (TTL disabled)")]
|
||||
public static partial void LogTTLExpirationTimeCleared(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,9 @@ public static class ServiceCollectionExtensions
|
||||
// The agent dictionary contains the real agent factories, which is used by the agent entities.
|
||||
services.AddSingleton(agents);
|
||||
|
||||
// Register the options so AgentEntity can access TTL configuration
|
||||
services.AddSingleton(options);
|
||||
|
||||
// The keyed services are used to resolve durable agent *proxy* instances for external clients.
|
||||
foreach (var factory in agents)
|
||||
{
|
||||
|
||||
@@ -17,6 +17,13 @@ internal sealed class DurableAgentStateData
|
||||
[JsonPropertyName("conversationHistory")]
|
||||
public IList<DurableAgentStateEntry> ConversationHistory { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the expiration time (UTC) for this agent entity.
|
||||
/// If the entity is idle beyond this time, it will be automatically deleted.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expirationTimeUtc")]
|
||||
public DateTime? ExpirationTimeUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any additional data found during deserialization that does not map to known properties.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user