.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:
Chris Gillum
2025-12-16 18:11:44 +00:00
committed by GitHub
parent b15466f058
commit 754dfb2c9d
9 changed files with 583 additions and 12 deletions
@@ -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>