diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/ConversationHookBase.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/ConversationHookBase.cs
index 5f86ba1f9..80e61bdb2 100644
--- a/src/Infrastructure/BotSharp.Abstraction/Conversations/ConversationHookBase.cs
+++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/ConversationHookBase.cs
@@ -86,4 +86,7 @@ public virtual Task OnBreakpointUpdated(string conversationId, bool resetStates)
public virtual Task OnNotificationGenerated(RoleDialogModel message)
=> Task.CompletedTask;
+
+ public virtual Task OnConversationCompressed(string conversationId, ConversationBreakpoint breakpoint, int archivedCount)
+ => Task.CompletedTask;
}
diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/Enums/MessageTypeName.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/Enums/MessageTypeName.cs
index c1ab00028..5339ae824 100644
--- a/src/Infrastructure/BotSharp.Abstraction/Conversations/Enums/MessageTypeName.cs
+++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Enums/MessageTypeName.cs
@@ -6,4 +6,5 @@ public static class MessageTypeName
public const string FunctionCall = "function";
public const string Audio = "audio";
public const string Error = "error";
+ public const string Summary = "summary";
}
diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationHook.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationHook.cs
index c1494f876..829dad366 100644
--- a/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationHook.cs
+++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationHook.cs
@@ -123,4 +123,14 @@ public interface IConversationHook : IHookBase
///
///
Task OnNotificationGenerated(RoleDialogModel message);
+
+ ///
+ /// Triggered after a conversation was auto-compressed (old turns summarized/archived).
+ ///
+ ///
+ /// The breakpoint written at the compression cut point.
+ /// Number of raw dialogs archived (0 when storage was not compacted).
+ ///
+ Task OnConversationCompressed(string conversationId, ConversationBreakpoint breakpoint, int archivedCount)
+ => Task.CompletedTask;
}
diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationService.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationService.cs
index aec49b9a0..bdfa88a47 100644
--- a/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationService.cs
+++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/IConversationService.cs
@@ -54,6 +54,15 @@ Task SendMessage(string agentId,
Task GetConversationSummary(ConversationSummaryModel model);
+ ///
+ /// Auto-compress a long conversation: summarize old turns, set a breakpoint so the LLM sees
+ /// [summary] + recent turns, and optionally archive the old raw dialogs out of the hot record.
+ /// No-op when auto-compression is disabled or the trigger threshold is not reached.
+ ///
+ ///
+ /// True when the conversation was compressed.
+ Task AutoCompressIfNeeded(string conversationId) => Task.FromResult(false);
+
Task GetConversationRecordOrCreateNew(string agentId);
bool IsConversationMode();
diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs
index 488862bd6..12017613b 100644
--- a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs
+++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs
@@ -32,6 +32,29 @@ public class Conversation
public int DialogCount { get; set; }
+ ///
+ /// MessageId of the cut point of the last auto-compression. Everything before it has been
+ /// summarized/archived. Null when the conversation has never been compressed.
+ ///
+ public string? LastCompactedMessageId { get; set; }
+
+ ///
+ /// Number of raw dialogs that have been archived out of the hot dialog record by auto-compression.
+ ///
+ public int CompactedDialogCount { get; set; }
+
+ ///
+ /// True while an auto-compression is in progress for this conversation. Used as a guard so a new
+ /// compaction request is skipped while one is already running; cleared when the run finishes.
+ ///
+ public bool IsCompressing { get; set; }
+
+ ///
+ /// When the current compaction was marked in progress. Used to detect and take over a stale flag
+ /// left behind by a crashed run.
+ ///
+ public DateTime? CompressingStartedTime { get; set; }
+
public List Tags { get; set; } = [];
public DateTime UpdatedTime { get; set; } = DateTime.UtcNow;
diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/Settings/ConversationSetting.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/Settings/ConversationSetting.cs
index 018f6086c..f796c150f 100644
--- a/src/Infrastructure/BotSharp.Abstraction/Conversations/Settings/ConversationSetting.cs
+++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Settings/ConversationSetting.cs
@@ -15,6 +15,54 @@ public class ConversationSetting
public CleanConversationSetting CleanSetting { get; set; } = new();
public RateLimitSetting RateLimit { get; set; } = new();
public FileSelectSetting? FileSelect { get; set; }
+ public AutoCompressionSetting AutoCompression { get; set; } = new();
+}
+
+///
+/// Automatically compress a long conversation once its uncompressed message count grows large,
+/// keeping recent turns verbatim and replacing older turns with a generated summary.
+///
+public class AutoCompressionSetting
+{
+ ///
+ /// Enable the context layer: summarize old turns and set a breakpoint so the LLM sees
+ /// [summary] + recent turns. Fully reversible, no stored data is removed.
+ ///
+ public bool Enabled { get; set; } = false;
+
+ ///
+ /// Enable the storage layer: physically archive old raw dialogs out of the hot dialog record
+ /// and replace them with a single summary dialog. Requires .
+ ///
+ public bool CompactStorage { get; set; } = false;
+
+ ///
+ /// Compress when (DialogCount - CompactedDialogCount) reaches this many messages.
+ ///
+ public int TriggerMessageCount { get; set; } = 500;
+
+ ///
+ /// Number of most recent messages always kept verbatim (never summarized).
+ ///
+ public int KeepRecentCount { get; set; } = 100;
+
+ ///
+ /// Agent that owns the summary template and whose LLM config (provider, model, max output tokens,
+ /// reasoning level, response format) is used to generate the summary.
+ /// When not set, the conversation's own agent is used.
+ ///
+ public string SummaryAgentId { get; set; } = string.Empty;
+
+ ///
+ /// Template used to generate the compression summary.
+ ///
+ public string SummaryTemplateName { get; set; } = "conversation.compression";
+
+ ///
+ /// Staleness guard for the persisted compressing flag: if a compaction has been marked in progress
+ /// for longer than this (e.g. the process crashed mid-run), the next request is allowed to take over.
+ ///
+ public int CompressionTimeoutSeconds { get; set; } = 300;
}
public class CleanConversationSetting
diff --git a/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs b/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs
index 81657b651..aa30a06c0 100644
--- a/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs
+++ b/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs
@@ -165,6 +165,27 @@ Task> GetIdleConversations(int batchSize, int messageLimit, int buf
=> throw new NotImplementedException();
Task> TruncateConversation(string conversationId, string messageId, bool cleanLog = false)
=> throw new NotImplementedException();
+ ///
+ /// Compact a conversation's dialog history: archive every dialog before
+ /// and replace them in the hot dialog record with . Stale versioned
+ /// state history older than the cut is also trimmed (the latest value of each key is preserved).
+ ///
+ ///
+ /// First message to KEEP; everything strictly before it is archived.
+ /// Summary dialog inserted at the head of the kept messages.
+ /// When true, archived dialogs are retained; otherwise discarded.
+ /// Number of dialogs archived, or 0 when nothing was compacted.
+ Task CompactConversationDialogs(string conversationId, string cutMessageId, DialogElement summaryDialog, bool archiveRawDialogs = true)
+ => throw new NotImplementedException();
+ ///
+ /// Set the conversation's auto-compression flag.
+ /// When is true this is an atomic acquire: it returns true only if the
+ /// flag was previously off — or was on but older than (a crashed run) so
+ /// this caller takes it over — and false if a compaction is already running and still fresh.
+ /// When false it clears the flag (release) and returns true.
+ ///
+ Task UpdateConversationCompressing(string conversationId, bool isCompressing, TimeSpan? staleAfter = null)
+ => throw new NotImplementedException();
Task> GetConversationStateSearchKeys(ConversationStateKeysFilter filter)
=> throw new NotImplementedException();
Task> GetConversationsToMigrate(int batchSize = 100)
diff --git a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.Compression.cs b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.Compression.cs
new file mode 100644
index 000000000..fe90742a4
--- /dev/null
+++ b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.Compression.cs
@@ -0,0 +1,213 @@
+using System.Collections.Concurrent;
+
+namespace BotSharp.Core.Conversations.Services;
+
+public partial class ConversationService
+{
+ // Conversations currently being compressed on THIS instance. A cheap local fast-path in front of
+ // the persisted (authoritative, cross-instance) compressing flag.
+ private static readonly ConcurrentDictionary _localCompressing = new();
+
+ public async Task AutoCompressIfNeeded(string conversationId)
+ {
+ var setting = _settings.AutoCompression;
+ if (setting == null || !setting.Enabled || string.IsNullOrEmpty(conversationId))
+ {
+ return false;
+ }
+
+ // Local fast-path guard: skip if a compaction for this conversation is already running on
+ // this instance, avoiding a redundant DB acquire round-trip. Non-blocking (skip, not queue).
+ if (!_localCompressing.TryAdd(conversationId, 0))
+ {
+ return false;
+ }
+
+ var db = _services.GetRequiredService();
+
+ // Persisted guard: atomically turn the conversation's compressing flag on. If another
+ // compaction is already running (flag on and not stale), skip this request. A flag left on
+ // longer than CompressionTimeoutSeconds (e.g. a crashed run) is treated as stale and taken over.
+ var staleAfter = TimeSpan.FromSeconds(Math.Max(60, setting.CompressionTimeoutSeconds));
+ if (!await db.UpdateConversationCompressing(conversationId, true, staleAfter))
+ {
+ _localCompressing.TryRemove(conversationId, out _);
+ return false;
+ }
+
+ try
+ {
+ var conv = await db.GetConversation(conversationId);
+ if (conv == null)
+ {
+ return false;
+ }
+
+ // Load the full stored history in chronological order.
+ var dialogs = await _storage.GetDialogs(conversationId);
+ if (dialogs.IsNullOrEmpty())
+ {
+ return false;
+ }
+
+ // Trigger is relative to the last breakpoint: only count the messages the LLM currently
+ // sees. This keeps a single, stable threshold across both context-only and storage modes
+ // and prevents re-firing on every turn once the total count is large.
+ var lastBreakpoint = await db.GetConversationBreakpoint(conversationId);
+ var activeCount = lastBreakpoint != null
+ ? dialogs.Count(x => x.CreatedAt >= lastBreakpoint.Breakpoint)
+ : dialogs.Count;
+
+ if (activeCount < setting.TriggerMessageCount)
+ {
+ return false;
+ }
+
+ // Keep the most recent messages verbatim; summarize everything before the cut,
+ // snapped to a safe turn boundary.
+ var cutIndex = ComputeCompressionCut(dialogs, setting.KeepRecentCount);
+ if (cutIndex <= 0)
+ {
+ return false;
+ }
+
+ var cutDialog = dialogs[cutIndex];
+ var toSummarize = dialogs.Take(cutIndex).ToList();
+ if (toSummarize.IsNullOrEmpty())
+ {
+ return false;
+ }
+
+ // Use the configured summary agent, or fall back to the conversation's own agent.
+ var summaryAgentId = string.IsNullOrEmpty(setting.SummaryAgentId) ? conv.AgentId : setting.SummaryAgentId;
+ var summary = await SummarizeForCompression(summaryAgentId, setting, toSummarize);
+ if (string.IsNullOrWhiteSpace(summary))
+ {
+ _logger.LogWarning($"Auto-compression produced an empty summary for conversation {conversationId}; skipping.");
+ return false;
+ }
+
+ // Context layer: a breakpoint at the cut so GetDialogHistory drops older raw turns and
+ // injects the summary as a leading message. The last breakpoint always wins.
+ var breakpoint = new ConversationBreakpoint
+ {
+ MessageId = cutDialog.MessageId,
+ Breakpoint = cutDialog.CreatedAt,
+ Reason = summary,
+ CreatedTime = DateTime.UtcNow
+ };
+ await db.UpdateConversationBreakpoint(conversationId, breakpoint);
+
+ // Storage layer: physically archive old raw dialogs and replace them with a summary marker.
+ var archivedCount = 0;
+ if (setting.CompactStorage)
+ {
+ var summaryDialog = new DialogElement
+ {
+ // Timestamp just before the cut so the breakpoint filter and message-type filter
+ // both keep this marker out of the LLM history (the breakpoint reason carries it).
+ MetaData = new DialogMetaData
+ {
+ Role = AgentRole.System,
+ AgentId = conv.AgentId,
+ MessageId = Guid.NewGuid().ToString(),
+ MessageType = MessageTypeName.Summary,
+ MessageLabel = "compression_summary",
+ ExcludeFromContext = true,
+ CreatedTime = cutDialog.CreatedAt.AddTicks(-1)
+ },
+ Content = summary
+ };
+
+ archivedCount = await db.CompactConversationDialogs(conversationId, cutDialog.MessageId, summaryDialog, archiveRawDialogs: true);
+ }
+
+ _logger.LogInformation($"Auto-compressed conversation {conversationId}: summarized {toSummarize.Count} messages, archived {archivedCount}.");
+
+ await HookEmitter.Emit(_services,
+ async hook => await hook.OnConversationCompressed(conversationId, breakpoint, archivedCount),
+ conv.AgentId);
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ // Compression must never break the conversation turn.
+ _logger.LogError(ex, $"Failed to auto-compress conversation {conversationId}.");
+ return false;
+ }
+ finally
+ {
+ // Release both guards so the next turn can compress again. Runs only because we acquired
+ // them above (a failed acquire returns before entering this try).
+ await db.UpdateConversationCompressing(conversationId, false);
+ _localCompressing.TryRemove(conversationId, out _);
+ }
+ }
+
+ ///
+ /// Index of the first message to KEEP: aims to keep the last
+ /// messages verbatim, then snaps the cut back to the start of a user turn so a tool-call pair
+ /// (assistant tool call + its function result) is never split across the boundary.
+ /// Returns a value <= 0 when there is nothing worth compressing.
+ ///
+ internal static int ComputeCompressionCut(List dialogs, int keepRecentCount)
+ {
+ if (dialogs == null || dialogs.Count == 0)
+ {
+ return -1;
+ }
+
+ var cutIndex = dialogs.Count - Math.Max(0, keepRecentCount);
+ while (cutIndex > 0 && dialogs[cutIndex].Role != AgentRole.User)
+ {
+ cutIndex--;
+ }
+
+ return cutIndex;
+ }
+
+ private async Task SummarizeForCompression(string summaryAgentId, AutoCompressionSetting setting, List dialogs)
+ {
+ var agentService = _services.GetRequiredService();
+ var summaryAgent = await agentService.LoadAgent(summaryAgentId);
+
+ if (summaryAgent == null)
+ {
+ _logger.LogWarning($"Summary agent '{summaryAgentId}' has no valid LLM config (provider/model); skipping auto-compression.");
+ return string.Empty;
+ }
+
+ // The summary agent must own the compression template.
+ var foundTemplate = summaryAgent.Templates?.FirstOrDefault(x => x.Name.IsEqualTo(setting.SummaryTemplateName));
+ if (foundTemplate == null || foundTemplate.LlmConfig?.IsValid != true)
+ {
+ _logger.LogWarning($"Summary agent '{summaryAgentId}' does not have template '{setting.SummaryTemplateName}'; skipping auto-compression.");
+ return string.Empty;
+ }
+
+ var content = GetConversationContent(dialogs, dialogs.Count);
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ return string.Empty;
+ }
+
+ var prompt = GetPrompt(summaryAgent, setting.SummaryTemplateName, new List { content });
+
+ var provider = foundTemplate.LlmConfig.Provider;
+ var model = foundTemplate.LlmConfig.Model;
+ var chatCompletion = CompletionProvider.GetChatCompletion(_services, provider, model);
+ var response = await chatCompletion.GetChatCompletions(new Agent
+ {
+ Id = summaryAgent.Id,
+ Name = summaryAgent.Name,
+ Instruction = prompt,
+ LlmConfig = new(foundTemplate.LlmConfig)
+ }, new List
+ {
+ new RoleDialogModel(AgentRole.User, "Please summarize the conversation above.")
+ });
+
+ return response.Content;
+ }
+}
diff --git a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.SendMessage.cs b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.SendMessage.cs
index c8775ea33..f7b25554c 100644
--- a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.SendMessage.cs
+++ b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.SendMessage.cs
@@ -110,9 +110,43 @@ public async Task SendMessage(string agentId,
await HandleAssistantMessage(response, onMessageReceived);
statistics.PrintStatistics();
+ TriggerAutoCompression(_conversationId);
+
return true;
}
+ ///
+ /// Fire-and-forget auto-compression on a fresh DI scope so it never adds latency to the
+ /// user-facing turn and does not use the (soon-to-be-disposed) request scope.
+ ///
+ private void TriggerAutoCompression(string conversationId)
+ {
+ if (!_settings.AutoCompression.Enabled || string.IsNullOrEmpty(conversationId))
+ {
+ return;
+ }
+
+ var scopeFactory = _services.GetService();
+ if (scopeFactory == null)
+ {
+ return;
+ }
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ using var scope = scopeFactory.CreateScope();
+ var conv = scope.ServiceProvider.GetRequiredService();
+ await conv.AutoCompressIfNeeded(conversationId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, $"Background auto-compression failed for conversation {conversationId}.");
+ }
+ });
+ }
+
private async Task HandleAssistantMessage(RoleDialogModel response, Func onResponseReceived)
{
var agentService = _services.GetRequiredService();
diff --git a/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs b/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs
index d1a2e9e86..589865909 100644
--- a/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs
+++ b/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs
@@ -430,30 +430,48 @@ public async Task GetConversation(string conversationId, bool isLo
}
var convFile = Path.Combine(convDir, CONVERSATION_FILE);
- var content = await File.ReadAllTextAsync(convFile);
- var record = JsonSerializer.Deserialize(content, _options);
-
- var dialogFile = Path.Combine(convDir, DIALOG_FILE);
- if (record != null)
+ if (!File.Exists(convFile))
{
- record.Dialogs = await CollectDialogElements(dialogFile);
+ return null;
}
- if (isLoadStates)
+ Conversation? record;
+ try
{
- var latestStateFile = Path.Combine(convDir, CONV_LATEST_STATE_FILE);
- if (record != null && File.Exists(latestStateFile))
+ var content = await File.ReadAllTextAsync(convFile);
+ record = JsonSerializer.Deserialize(content, _options);
+ if (record == null)
{
- var stateJson = await File.ReadAllTextAsync(latestStateFile);
- var states = JsonSerializer.Deserialize>(stateJson, _options) ?? [];
- record.States = states.ToDictionary(x => x.Key, x =>
+ return null;
+ }
+
+ var dialogFile = Path.Combine(convDir, DIALOG_FILE);
+ if (record != null)
+ {
+ record.Dialogs = await CollectDialogElements(dialogFile);
+ }
+
+ if (isLoadStates)
+ {
+ var latestStateFile = Path.Combine(convDir, CONV_LATEST_STATE_FILE);
+ if (record != null && File.Exists(latestStateFile))
{
- var elem = x.Value.RootElement.GetProperty("data");
- return elem.ValueKind != JsonValueKind.Null ? elem.ToString() : null;
- });
+ var stateJson = await File.ReadAllTextAsync(latestStateFile);
+ var states = JsonSerializer.Deserialize>(stateJson, _options) ?? [];
+ record.States = states.ToDictionary(x => x.Key, x =>
+ {
+ var elem = x.Value.RootElement.GetProperty("data");
+ return elem.ValueKind != JsonValueKind.Null ? elem.ToString() : null;
+ });
+ }
}
+ return record;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, $"Failed to read conversation record for conversation {conversationId}.");
+ return null;
}
- return record;
}
public async Task> GetConversations(ConversationFilter filter)
@@ -778,6 +796,261 @@ public async Task> TruncateConversation(string conversationId, stri
return deletedMessageIds;
}
+ public async Task UpdateConversationCompressing(string conversationId, bool isCompressing, TimeSpan? staleAfter = null)
+ {
+ var convDir = FindConversationDirectory(conversationId);
+ if (string.IsNullOrEmpty(convDir))
+ {
+ return false;
+ }
+
+ var convFile = Path.Combine(convDir, CONVERSATION_FILE);
+ if (!File.Exists(convFile))
+ {
+ return false;
+ }
+
+ // Serialize the flag read-modify-write on the same lock the append path uses for conversation.json.
+ await _dialogLock.WaitAsync();
+ try
+ {
+ var json = await File.ReadAllTextAsync(convFile);
+ var conv = JsonSerializer.Deserialize(json, _options);
+ if (conv == null)
+ {
+ return false;
+ }
+
+ var utcNow = DateTime.UtcNow;
+ if (isCompressing)
+ {
+ // Atomic acquire: fail if a compaction is already in progress and still fresh. A flag
+ // older than staleAfter is treated as abandoned (crashed run) and taken over.
+ if (conv.IsCompressing)
+ {
+ var stale = staleAfter.HasValue
+ && conv.CompressingStartedTime.HasValue
+ && utcNow - conv.CompressingStartedTime.Value >= staleAfter.Value;
+ if (!stale)
+ {
+ return false;
+ }
+ }
+ conv.IsCompressing = true;
+ conv.CompressingStartedTime = utcNow;
+ }
+ else
+ {
+ conv.IsCompressing = false;
+ conv.CompressingStartedTime = null;
+ }
+
+ conv.UpdatedTime = utcNow;
+ await File.WriteAllTextAsync(convFile, JsonSerializer.Serialize(conv, _options));
+ return true;
+ }
+ finally
+ {
+ _dialogLock.Release();
+ }
+ }
+
+ public async Task CompactConversationDialogs(string conversationId, string cutMessageId, DialogElement summaryDialog, bool archiveRawDialogs = true)
+ {
+ if (string.IsNullOrEmpty(conversationId) || string.IsNullOrEmpty(cutMessageId) || summaryDialog == null)
+ {
+ return 0;
+ }
+
+ var convDir = FindConversationDirectory(conversationId);
+ if (string.IsNullOrEmpty(convDir))
+ {
+ return 0;
+ }
+
+ var dialogDir = Path.Combine(convDir, DIALOG_FILE);
+
+ var archivedCount = 0;
+ var cutTime = DateTime.MinValue;
+ var archiveDir = Path.Combine(convDir, DIALOG_ARCHIVE_FOLDER, DateTime.UtcNow.ToString("yyyyMMddHHmmssfffffff"));
+
+ await _dialogLock.WaitAsync();
+ try
+ {
+ if (!File.Exists(dialogDir))
+ {
+ return 0;
+ }
+
+ var dialogs = new List();
+ try
+ {
+ var text = await File.ReadAllTextAsync(dialogDir);
+ dialogs = JsonSerializer.Deserialize>(text, _options) ?? [];
+ }
+ catch
+ {
+ dialogs = [];
+ }
+
+ if (dialogs.IsNullOrEmpty())
+ {
+ return 0;
+ }
+
+ var foundIdx = dialogs.FindIndex(x => x.MetaData?.MessageId == cutMessageId);
+ if (foundIdx <= 0)
+ {
+ return 0;
+ }
+
+ var archived = dialogs.Take(foundIdx).ToList();
+ var kept = dialogs.Skip(foundIdx).ToList();
+ cutTime = kept.FirstOrDefault()?.MetaData?.CreatedTime ?? DateTime.MinValue;
+
+ if (cutTime == DateTime.MinValue)
+ {
+ return 0;
+ }
+
+ // Archive raw dialogs into this run's timestamped archive subfolder.
+ if (archiveRawDialogs)
+ {
+ EnsureDirectory(archiveDir);
+ await File.WriteAllTextAsync(Path.Combine(archiveDir, DIALOG_FILE), ParseDialogElements(archived));
+ }
+
+ // Rewrite the hot dialog record as [summary] + kept.
+ var compacted = new List { summaryDialog };
+ compacted.AddRange(kept);
+ await File.WriteAllTextAsync(dialogDir, ParseDialogElements(compacted));
+
+ // Update conversation bookkeeping. DialogCount stays the true lifetime total;
+ // compaction progress is tracked separately.
+ var convFile = Path.Combine(convDir, CONVERSATION_FILE);
+ if (File.Exists(convFile))
+ {
+ var convJson = await File.ReadAllTextAsync(convFile);
+ var conv = JsonSerializer.Deserialize(convJson, _options);
+ if (conv != null)
+ {
+ conv.LastCompactedMessageId = cutMessageId;
+ conv.CompactedDialogCount += archived.Count;
+ conv.UpdatedTime = DateTime.UtcNow;
+ await File.WriteAllTextAsync(convFile, JsonSerializer.Serialize(conv, _options));
+ }
+ }
+
+ archivedCount = archived.Count;
+ }
+ finally
+ {
+ _dialogLock.Release();
+ }
+
+ // Trim stale state version history in a separate lock section so we never nest _stateLock
+ // inside _dialogLock. Safe to run independently: at worst the dialog file is compacted while
+ // states keep extra history (current state is always preserved).
+ if (archivedCount > 0)
+ {
+ await CompactConversationStates(convDir, cutTime, archiveRawDialogs, archiveDir);
+ }
+
+ return archivedCount;
+ }
+
+ ///
+ /// Trim versioned state history older than , always preserving the
+ /// latest value of each key so the conversation's current state is intact. Removed versions are
+ /// written to this run's archive subfolder () when is true.
+ ///
+ private async Task CompactConversationStates(string convDir, DateTime cutTime, bool archive, string archiveDir)
+ {
+ var stateFile = Path.Combine(convDir, STATE_FILE);
+ var latestStateFile = Path.Combine(convDir, CONV_LATEST_STATE_FILE);
+
+ await _stateLock.WaitAsync();
+ try
+ {
+ if (!File.Exists(stateFile))
+ {
+ return;
+ }
+
+ List states;
+ try
+ {
+ var text = await File.ReadAllTextAsync(stateFile);
+ states = JsonSerializer.Deserialize>(text, _options) ?? [];
+ }
+ catch
+ {
+ return;
+ }
+
+ if (states.IsNullOrEmpty())
+ {
+ return;
+ }
+
+ var removed = new List();
+ foreach (var state in states)
+ {
+ if (!state.Versioning || state.Values == null || state.Values.Count <= 1)
+ {
+ continue;
+ }
+
+ var lastIndex = state.Values.Count - 1;
+ var kept = new List();
+ var trimmed = new List();
+ for (var i = 0; i < state.Values.Count; i++)
+ {
+ var value = state.Values[i];
+ // Always keep the newest value (current state); keep anything at/after the cut.
+ if (i == lastIndex || value.UpdateTime >= cutTime)
+ {
+ kept.Add(value);
+ }
+ else
+ {
+ trimmed.Add(value);
+ }
+ }
+
+ if (trimmed.Count > 0)
+ {
+ removed.Add(new StateKeyValue(state.Key, trimmed)
+ {
+ Versioning = state.Versioning,
+ Readonly = state.Readonly
+ });
+ state.Values = kept;
+ }
+ }
+
+ if (removed.IsNullOrEmpty())
+ {
+ return;
+ }
+
+ // Archive the removed state versions into the same run subfolder as the dialogs.
+ if (archive)
+ {
+ EnsureDirectory(archiveDir);
+ await File.WriteAllTextAsync(Path.Combine(archiveDir, STATE_FILE), JsonSerializer.Serialize(removed, _options));
+ }
+
+ // Rewrite the trimmed state file.
+ SaveTruncatedStates(stateFile, states);
+ //SaveTruncatedLatestStates(latestStateFile, states);
+ }
+ finally
+ {
+ _stateLock.Release();
+ }
+ }
+
#if !DEBUG
[SharpCache(10)]
#endif
diff --git a/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.cs b/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.cs
index 3725c1648..37d0c43de 100644
--- a/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.cs
+++ b/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.cs
@@ -33,6 +33,7 @@ public partial class FileRepository : IBotSharpRepository
private const string CONVERSATION_FILE = "conversation.json";
private const string DIALOG_FILE = "dialogs.json";
+ private const string DIALOG_ARCHIVE_FOLDER = "archive";
private const string STATE_FILE = "state.json";
private const string BREAKPOINT_FILE = "breakpoint.json";
private const string CONV_LATEST_STATE_FILE = "latest-state.json";
@@ -528,5 +529,13 @@ private string[] ParseFileNameByPath(string path, string separator = ".")
return (configs, configFile);
}
}
+
+ private static void EnsureDirectory(string dir)
+ {
+ if (!Directory.Exists(dir))
+ {
+ Directory.CreateDirectory(dir);
+ }
+ }
#endregion
}
diff --git a/src/Infrastructure/BotSharp.Core/data/agents/01fcc3e5-9af7-49e6-ad7a-a760bd12dc4a/templates/conversation.compression.liquid b/src/Infrastructure/BotSharp.Core/data/agents/01fcc3e5-9af7-49e6-ad7a-a760bd12dc4a/templates/conversation.compression.liquid
new file mode 100644
index 000000000..f508ef268
--- /dev/null
+++ b/src/Infrastructure/BotSharp.Core/data/agents/01fcc3e5-9af7-49e6-ad7a-a760bd12dc4a/templates/conversation.compression.liquid
@@ -0,0 +1,24 @@
+You are compressing the earlier part of an ongoing conversation so it can be dropped from the live context window while preserving everything needed to continue seamlessly.
+
+Read the [CONVERSATIONS] section (oldest to newest, and it may already begin with a previous summary) and produce a single consolidated summary.
+
+Preserve, in this order:
+1. The user's goals, requests, and any explicit instructions or constraints.
+2. Decisions made and their rationale.
+3. Concrete facts, entities, identifiers, values, and file/resource names that later turns may reference.
+4. Actions taken (including tool/function calls and their outcomes) and current status.
+5. Open questions, pending tasks, and anything unresolved.
+
+Rules:
+* Fold any pre-existing summary into the new one so the result is cumulative and self-contained.
+* Be faithful: do not invent, infer, or add information that is not present.
+* Drop greetings, pleasantries, and redundant back-and-forth.
+* Write in compact, factual notes (short paragraphs or bullet points). This is context for an AI, not prose for a human.
+* Do not answer or continue the conversation. Only summarize it.
+
+[CONVERSATIONS]
+
+{% for text in texts -%}
+[CONVERSATION]
+{{ text }}{{ "\r\n" }}
+{%- endfor %}
diff --git a/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationArchiveDocument.cs b/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationArchiveDocument.cs
new file mode 100644
index 000000000..643f017ab
--- /dev/null
+++ b/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationArchiveDocument.cs
@@ -0,0 +1,15 @@
+namespace BotSharp.Plugin.MongoStorage.Collections;
+
+///
+/// One record per compaction run: the raw dialogs and trimmed state versions that were archived
+/// out of the hot conversation records. Parallels the timestamped archive folder in file storage.
+///
+public class ConversationArchiveDocument : MongoBase
+{
+ public string ConversationId { get; set; } = default!;
+ public string AgentId { get; set; } = default!;
+ public string? CutMessageId { get; set; }
+ public DateTime ArchivedTime { get; set; }
+ public List Dialogs { get; set; } = [];
+ public List States { get; set; } = [];
+}
diff --git a/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDocument.cs b/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDocument.cs
index ac0287316..d444dfccf 100644
--- a/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDocument.cs
+++ b/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDocument.cs
@@ -11,6 +11,10 @@ public class ConversationDocument : MongoBase
public string ChannelId { get; set; } = default!;
public string Status { get; set; } = default!;
public int DialogCount { get; set; }
+ public string? LastCompactedMessageId { get; set; }
+ public int CompactedDialogCount { get; set; }
+ public bool IsCompressing { get; set; }
+ public DateTime? CompressingStartedTime { get; set; }
public List Tags { get; set; } = [];
public DateTime CreatedTime { get; set; }
diff --git a/src/Plugins/BotSharp.Plugin.MongoStorage/MongoDbContext.cs b/src/Plugins/BotSharp.Plugin.MongoStorage/MongoDbContext.cs
index 9836b571e..24db5faf7 100644
--- a/src/Plugins/BotSharp.Plugin.MongoStorage/MongoDbContext.cs
+++ b/src/Plugins/BotSharp.Plugin.MongoStorage/MongoDbContext.cs
@@ -89,6 +89,7 @@ private void CreateIndexes()
// Perform index creation (only executed on the first call).
CreateConversationIndex();
CreateConversationStateIndex();
+ CreateConversationArchiveIndex();
CreateContentLogIndex();
CreateStateLogIndex();
CreateInstructionLogIndex();
@@ -143,6 +144,17 @@ private IMongoCollection CreateConversationStateIndex
return collection;
}
+ private IMongoCollection CreateConversationArchiveIndex()
+ {
+ var collection = GetCollectionOrCreate("ConversationArchives");
+ var curIndexes = collection.Indexes.List().ToList().Where(x => x.Contains("name")).Select(x => x["name"].AsString);
+ if (!curIndexes.Any(x => x.StartsWith("ConversationId")))
+ {
+ CreateIndex(collection, Builders.IndexKeys.Ascending(x => x.ConversationId));
+ }
+ return collection;
+ }
+
private IMongoCollection CreateAgentTaskIndex()
{
var collection = GetCollectionOrCreate("AgentTasks");
@@ -216,6 +228,9 @@ public IMongoCollection ConversationDialogs
public IMongoCollection ConversationStates
=> GetCollection("ConversationStates");
+ public IMongoCollection ConversationArchives
+ => GetCollection("ConversationArchives");
+
public IMongoCollection ConversationFiles
=> GetCollection("ConversationFiles");
diff --git a/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Conversation.cs b/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Conversation.cs
index a003b3833..a7eab043f 100644
--- a/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Conversation.cs
+++ b/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Conversation.cs
@@ -75,6 +75,7 @@ public async Task DeleteConversations(IEnumerable conversationIds)
var filterContentLog = Builders.Filter.In(x => x.ConversationId, conversationIds);
var filterStateLog = Builders.Filter.In(x => x.ConversationId, conversationIds);
var conbTabItems = Builders.Filter.In(x => x.ConversationId, conversationIds);
+ //var filterArchive = Builders.Filter.In(x => x.ConversationId, conversationIds);
//var filterConvFile = Builders.Filter.In(x => x.ConversationId, conversationIds);
var promptLogDeleted = await _dc.LlmCompletionLogs.DeleteManyAsync(filterPromptLog);
@@ -83,6 +84,7 @@ public async Task DeleteConversations(IEnumerable conversationIds)
var statesDeleted = await _dc.ConversationStates.DeleteManyAsync(filterSates);
var dialogDeleted = await _dc.ConversationDialogs.DeleteManyAsync(filterDialog);
var cronDeleted = await _dc.CrontabItems.DeleteManyAsync(conbTabItems);
+ //var archiveDeleted = await _dc.ConversationArchives.DeleteManyAsync(filterArchive);
//var fileDeleted = await _dc.ConversationFiles.DeleteManyAsync(filterConvFile);
var convDeleted = await _dc.Conversations.DeleteManyAsync(filterConv);
@@ -353,6 +355,10 @@ public async Task GetConversation(string conversationId, bool isLo
Dialogs = dialogElements,
States = curStates,
DialogCount = conv.DialogCount,
+ LastCompactedMessageId = conv.LastCompactedMessageId,
+ CompactedDialogCount = conv.CompactedDialogCount,
+ IsCompressing = conv.IsCompressing,
+ CompressingStartedTime = conv.CompressingStartedTime,
Tags = conv.Tags,
CreatedTime = conv.CreatedTime,
UpdatedTime = conv.UpdatedTime
@@ -708,6 +714,160 @@ public async Task> TruncateConversation(string conversationId, stri
return deletedMessageIds;
}
+ public async Task UpdateConversationCompressing(string conversationId, bool isCompressing, TimeSpan? staleAfter = null)
+ {
+ if (string.IsNullOrEmpty(conversationId))
+ {
+ return false;
+ }
+
+ var utcNow = DateTime.UtcNow;
+ var builder = Builders.Filter;
+
+ if (isCompressing)
+ {
+ // Acquire when the flag is off/missing, or when it is on but was started before the
+ // staleness cutoff (a crashed run) so we can take it over. Single atomic update.
+ var acquirable = builder.Ne(x => x.IsCompressing, true);
+ if (staleAfter.HasValue)
+ {
+ var cutoff = utcNow - staleAfter.Value;
+ acquirable = builder.Or(acquirable, builder.Lte(x => x.CompressingStartedTime, cutoff));
+ }
+
+ var filter = builder.And(builder.Eq(x => x.Id, conversationId), acquirable);
+ var update = Builders.Update.Set(x => x.IsCompressing, true)
+ .Set(x => x.CompressingStartedTime, utcNow)
+ .Set(x => x.UpdatedTime, utcNow);
+ var result = await _dc.Conversations.UpdateOneAsync(filter, update);
+ return result.ModifiedCount > 0;
+ }
+ else
+ {
+ var filter = builder.Eq(x => x.Id, conversationId);
+ var update = Builders.Update.Set(x => x.IsCompressing, false)
+ .Set(x => x.CompressingStartedTime, (DateTime?)null)
+ .Set(x => x.UpdatedTime, utcNow);
+ await _dc.Conversations.UpdateOneAsync(filter, update);
+ return true;
+ }
+ }
+
+ public async Task CompactConversationDialogs(string conversationId, string cutMessageId, DialogElement summaryDialog, bool archiveRawDialogs = true)
+ {
+ if (string.IsNullOrEmpty(conversationId) || string.IsNullOrEmpty(cutMessageId) || summaryDialog == null)
+ {
+ return 0;
+ }
+
+ var dialogFilter = Builders.Filter.Eq(x => x.ConversationId, conversationId);
+ var foundDialog = await _dc.ConversationDialogs.Find(dialogFilter).FirstOrDefaultAsync();
+ if (foundDialog == null || foundDialog.Dialogs.IsNullOrEmpty())
+ {
+ return 0;
+ }
+
+ var foundIdx = foundDialog.Dialogs.FindIndex(x => x.MetaData?.MessageId == cutMessageId);
+ // Nothing to compact if the cut point is missing or already at the head.
+ if (foundIdx <= 0)
+ {
+ return 0;
+ }
+
+ var archivedDialogs = foundDialog.Dialogs.Where((x, idx) => idx < foundIdx).ToList();
+ var kept = foundDialog.Dialogs.Where((x, idx) => idx >= foundIdx).ToList();
+ // Boundary time of the cut message; used to trim state version history below.
+ var cutTime = foundDialog.Dialogs[foundIdx].MetaData?.CreateTime ?? DateTime.MinValue;
+ if (cutTime == DateTime.MinValue)
+ {
+ return 0;
+ }
+
+ // Trim versioned state history older than the cut, always preserving the latest value of
+ // each key so the conversation's current state is intact.
+ var stateFilter = Builders.Filter.Eq(x => x.ConversationId, conversationId);
+ var foundStates = await _dc.ConversationStates.Find(stateFilter).FirstOrDefaultAsync();
+ var removedStates = new List();
+ if (foundStates != null && !foundStates.States.IsNullOrEmpty())
+ {
+ foreach (var state in foundStates.States)
+ {
+ if (!state.Versioning || state.Values == null || state.Values.Count <= 1)
+ {
+ continue;
+ }
+
+ var lastIndex = state.Values.Count - 1;
+ var keptValues = new List();
+ var trimmedValues = new List();
+ for (var i = 0; i < state.Values.Count; i++)
+ {
+ var value = state.Values[i];
+ // Always keep the newest value (current state); keep anything at/after the cut.
+ if (i == lastIndex || value.UpdateTime >= cutTime)
+ {
+ keptValues.Add(value);
+ }
+ else
+ {
+ trimmedValues.Add(value);
+ }
+ }
+
+ if (trimmedValues.Count > 0)
+ {
+ removedStates.Add(new StateMongoElement
+ {
+ Key = state.Key,
+ Versioning = state.Versioning,
+ Readonly = state.Readonly,
+ Values = trimmedValues
+ });
+ state.Values = keptValues;
+ }
+ }
+ }
+
+ // Archive the removed dialogs and state versions into the dedicated archive collection.
+ if (archiveRawDialogs)
+ {
+ var archiveDoc = new ConversationArchiveDocument
+ {
+ Id = Guid.NewGuid().ToString(),
+ ConversationId = conversationId,
+ AgentId = foundDialog.AgentId,
+ CutMessageId = cutMessageId,
+ ArchivedTime = DateTime.UtcNow,
+ Dialogs = archivedDialogs,
+ States = removedStates
+ };
+ await _dc.ConversationArchives.InsertOneAsync(archiveDoc);
+ }
+
+ // Rewrite the hot dialog record as [summary] + kept.
+ var compacted = new List { DialogMongoElement.ToMongoElement(summaryDialog) };
+ compacted.AddRange(kept);
+ foundDialog.Dialogs = compacted;
+ foundDialog.UpdatedTime = DateTime.UtcNow;
+ await _dc.ConversationDialogs.ReplaceOneAsync(dialogFilter, foundDialog);
+
+ // Persist the trimmed states. The latest value per key is unchanged, so LatestStates is left as-is.
+ if (removedStates.Count > 0)
+ {
+ foundStates!.UpdatedTime = DateTime.UtcNow;
+ await _dc.ConversationStates.ReplaceOneAsync(stateFilter, foundStates);
+ }
+
+ // DialogCount stays the true lifetime total; compaction progress is tracked separately.
+ var convFilter = Builders.Filter.Eq(x => x.Id, conversationId);
+ var updateConv = Builders.Update.Set(x => x.UpdatedTime, DateTime.UtcNow)
+ .Set(x => x.LastCompactedMessageId, cutMessageId)
+ .Inc(x => x.CompactedDialogCount, archivedDialogs.Count);
+ await _dc.Conversations.UpdateOneAsync(convFilter, updateConv);
+
+ return archivedDialogs.Count;
+ }
+
#if !DEBUG
[SharpCache(10)]
#endif
diff --git a/src/WebStarter/appsettings.json b/src/WebStarter/appsettings.json
index 7688d81ea..24fdda243 100644
--- a/src/WebStarter/appsettings.json
+++ b/src/WebStarter/appsettings.json
@@ -719,6 +719,15 @@
"MaxOutputTokens": 8192,
"ReasoningEffortLevel": "low",
"MessageLimit": 50
+ },
+ "AutoCompression": {
+ "Enabled": false,
+ "CompactStorage": false,
+ "TriggerMessageCount": 500,
+ "KeepRecentCount": 100,
+ "SummaryAgentId": "",
+ "SummaryTemplateName": "conversation.compression",
+ "CompressionTimeoutSeconds": 300
}
},