From eb0c0bedfd9b5c15e5c8558d9f5f07342006ea22 Mon Sep 17 00:00:00 2001 From: Jicheng Lu <103353@smsassist.com> Date: Tue, 7 Jul 2026 17:52:08 -0500 Subject: [PATCH 1/3] init --- .../Conversations/ConversationHookBase.cs | 3 + .../Conversations/Enums/MessageTypeName.cs | 1 + .../Conversations/IConversationHook.cs | 10 + .../Conversations/IConversationService.cs | 9 + .../Conversations/Models/Conversation.cs | 11 + .../Settings/ConversationSetting.cs | 42 ++++ .../Repositories/IBotSharpRepository.cs | 12 ++ .../ConversationService.Compression.cs | 200 ++++++++++++++++++ .../ConversationService.SendMessage.cs | 34 +++ .../FileRepository.Conversation.cs | 196 +++++++++++++++++ .../FileRepository/FileRepository.cs | 9 + .../templates/conversation.compression.liquid | 24 +++ .../Collections/ConversationDialogDocument.cs | 5 + .../Collections/ConversationDocument.cs | 2 + .../MongoRepository.Conversation.cs | 46 ++++ 15 files changed, 604 insertions(+) create mode 100644 src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.Compression.cs create mode 100644 src/Infrastructure/BotSharp.Core/data/agents/01fcc3e5-9af7-49e6-ad7a-a760bd12dc4a/templates/conversation.compression.liquid 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..ed5eb4a2e 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs @@ -32,6 +32,17 @@ 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; } + 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..22cc8d3bc 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/Settings/ConversationSetting.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Settings/ConversationSetting.cs @@ -15,6 +15,48 @@ 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"; } public class CleanConversationSetting diff --git a/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs b/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs index 81657b651..e05e236f3 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs @@ -165,6 +165,18 @@ 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(); 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..994d6f228 --- /dev/null +++ b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.Compression.cs @@ -0,0 +1,200 @@ +using System.Collections.Concurrent; + +namespace BotSharp.Core.Conversations.Services; + +public partial class ConversationService +{ + // Conversations currently being compressed. Compression runs fire-and-forget after every turn, + // and summarization is a slow LLM call, so multiple runs can overlap for the same conversation. + // This guard lets only one run per conversation proceed; overlapping ones skip and are picked up + // by the next turn's trigger once the in-flight run finishes. + private static readonly ConcurrentDictionary _compressingConversations = new(); + + public async Task AutoCompressIfNeeded(string conversationId) + { + var setting = _settings.AutoCompression; + if (setting == null || !setting.Enabled || string.IsNullOrEmpty(conversationId)) + { + return false; + } + + // Skip if a compaction for this conversation is already running. + if (!_compressingConversations.TryAdd(conversationId, 0)) + { + return false; + } + + try + { + var db = _services.GetRequiredService(); + 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 + { + _compressingConversations.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..b612e2175 100644 --- a/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs +++ b/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs @@ -778,6 +778,202 @@ public async Task> TruncateConversation(string conversationId, stri return deletedMessageIds; } + 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/ConversationDialogDocument.cs b/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDialogDocument.cs index 4f8966784..1cbacb40b 100644 --- a/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDialogDocument.cs +++ b/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDialogDocument.cs @@ -7,4 +7,9 @@ public class ConversationDialogDocument : MongoBase public string UserId { get; set; } = default!; public DateTime UpdatedTime { get; set; } public List Dialogs { get; set; } = []; + + /// + /// Raw dialogs moved out of by auto-compression. Retained for audit/recovery. + /// + public List ArchivedDialogs { get; set; } = []; } diff --git a/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDocument.cs b/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDocument.cs index ac0287316..ee0025a0b 100644 --- a/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDocument.cs +++ b/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDocument.cs @@ -11,6 +11,8 @@ 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 List Tags { get; set; } = []; public DateTime CreatedTime { get; set; } diff --git a/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Conversation.cs b/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Conversation.cs index a003b3833..d4614f256 100644 --- a/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Conversation.cs +++ b/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Conversation.cs @@ -353,6 +353,8 @@ public async Task GetConversation(string conversationId, bool isLo Dialogs = dialogElements, States = curStates, DialogCount = conv.DialogCount, + LastCompactedMessageId = conv.LastCompactedMessageId, + CompactedDialogCount = conv.CompactedDialogCount, Tags = conv.Tags, CreatedTime = conv.CreatedTime, UpdatedTime = conv.UpdatedTime @@ -708,6 +710,50 @@ public async Task> TruncateConversation(string conversationId, stri return deletedMessageIds; } + 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 archived = foundDialog.Dialogs.Where((x, idx) => idx < foundIdx).ToList(); + var kept = foundDialog.Dialogs.Where((x, idx) => idx >= foundIdx).ToList(); + + var compacted = new List { DialogMongoElement.ToMongoElement(summaryDialog) }; + compacted.AddRange(kept); + foundDialog.Dialogs = compacted; + if (archiveRawDialogs) + { + foundDialog.ArchivedDialogs = (foundDialog.ArchivedDialogs ?? []).Concat(archived).ToList(); + } + foundDialog.UpdatedTime = DateTime.UtcNow; + await _dc.ConversationDialogs.ReplaceOneAsync(dialogFilter, foundDialog); + + // 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, archived.Count); + await _dc.Conversations.UpdateOneAsync(convFilter, updateConv); + + return archived.Count; + } + #if !DEBUG [SharpCache(10)] #endif From b5f3f64d7eb8dd43af9a83b10bde49ac83c736d6 Mon Sep 17 00:00:00 2001 From: Jicheng Lu <103353@smsassist.com> Date: Wed, 8 Jul 2026 17:28:26 -0500 Subject: [PATCH 2/3] use db flag to lock compression --- .../Conversations/Models/Conversation.cs | 12 ++ .../Settings/ConversationSetting.cs | 6 + .../Repositories/IBotSharpRepository.cs | 9 ++ .../ConversationService.Compression.cs | 31 +++-- .../FileRepository.Conversation.cs | 109 ++++++++++++--- .../ConversationArchiveDocument.cs | 15 ++ .../Collections/ConversationDialogDocument.cs | 5 - .../Collections/ConversationDocument.cs | 2 + .../MongoDbContext.cs | 15 ++ .../MongoRepository.Conversation.cs | 128 +++++++++++++++++- 10 files changed, 295 insertions(+), 37 deletions(-) create mode 100644 src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationArchiveDocument.cs diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs index ed5eb4a2e..12017613b 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs @@ -43,6 +43,18 @@ public class Conversation /// 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 22cc8d3bc..f796c150f 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/Settings/ConversationSetting.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Settings/ConversationSetting.cs @@ -57,6 +57,12 @@ public class AutoCompressionSetting /// 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 e05e236f3..aa30a06c0 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs @@ -177,6 +177,15 @@ Task> TruncateConversation(string conversationId, string messageId, /// 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 index 994d6f228..fe90742a4 100644 --- a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.Compression.cs +++ b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.Compression.cs @@ -4,11 +4,9 @@ namespace BotSharp.Core.Conversations.Services; public partial class ConversationService { - // Conversations currently being compressed. Compression runs fire-and-forget after every turn, - // and summarization is a slow LLM call, so multiple runs can overlap for the same conversation. - // This guard lets only one run per conversation proceed; overlapping ones skip and are picked up - // by the next turn's trigger once the in-flight run finishes. - private static readonly ConcurrentDictionary _compressingConversations = new(); + // 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) { @@ -18,15 +16,27 @@ public async Task AutoCompressIfNeeded(string conversationId) return false; } - // Skip if a compaction for this conversation is already running. - if (!_compressingConversations.TryAdd(conversationId, 0)) + // 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 db = _services.GetRequiredService(); var conv = await db.GetConversation(conversationId); if (conv == null) { @@ -128,7 +138,10 @@ await HookEmitter.Emit(_services, } finally { - _compressingConversations.TryRemove(conversationId, out _); + // 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 _); } } diff --git a/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs b/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs index b612e2175..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,65 @@ 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) 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/ConversationDialogDocument.cs b/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDialogDocument.cs index 1cbacb40b..4f8966784 100644 --- a/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDialogDocument.cs +++ b/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDialogDocument.cs @@ -7,9 +7,4 @@ public class ConversationDialogDocument : MongoBase public string UserId { get; set; } = default!; public DateTime UpdatedTime { get; set; } public List Dialogs { get; set; } = []; - - /// - /// Raw dialogs moved out of by auto-compression. Retained for audit/recovery. - /// - public List ArchivedDialogs { get; set; } = []; } diff --git a/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDocument.cs b/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDocument.cs index ee0025a0b..d444dfccf 100644 --- a/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDocument.cs +++ b/src/Plugins/BotSharp.Plugin.MongoStorage/Collections/ConversationDocument.cs @@ -13,6 +13,8 @@ public class ConversationDocument : MongoBase 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 d4614f256..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); @@ -355,6 +357,8 @@ public async Task GetConversation(string conversationId, bool isLo DialogCount = conv.DialogCount, LastCompactedMessageId = conv.LastCompactedMessageId, CompactedDialogCount = conv.CompactedDialogCount, + IsCompressing = conv.IsCompressing, + CompressingStartedTime = conv.CompressingStartedTime, Tags = conv.Tags, CreatedTime = conv.CreatedTime, UpdatedTime = conv.UpdatedTime @@ -710,6 +714,45 @@ 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) @@ -731,27 +774,98 @@ public async Task CompactConversationDialogs(string conversationId, string return 0; } - var archived = foundDialog.Dialogs.Where((x, idx) => idx < foundIdx).ToList(); + 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; + } - var compacted = new List { DialogMongoElement.ToMongoElement(summaryDialog) }; - compacted.AddRange(kept); - foundDialog.Dialogs = compacted; + // 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) { - foundDialog.ArchivedDialogs = (foundDialog.ArchivedDialogs ?? []).Concat(archived).ToList(); + 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, archived.Count); + .Inc(x => x.CompactedDialogCount, archivedDialogs.Count); await _dc.Conversations.UpdateOneAsync(convFilter, updateConv); - return archived.Count; + return archivedDialogs.Count; } #if !DEBUG From b3451b980e86ac2e21d4265f7b95f62b747f8f0c Mon Sep 17 00:00:00 2001 From: Jicheng Lu <103353@smsassist.com> Date: Wed, 8 Jul 2026 17:34:47 -0500 Subject: [PATCH 3/3] add default settings --- src/WebStarter/appsettings.json | 9 +++++++++ 1 file changed, 9 insertions(+) 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 } },