Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,14 @@ public interface IConversationHook : IHookBase
/// <param name="message"></param>
/// <returns></returns>
Task OnNotificationGenerated(RoleDialogModel message);

/// <summary>
/// Triggered after a conversation was auto-compressed (old turns summarized/archived).
/// </summary>
/// <param name="conversationId"></param>
/// <param name="breakpoint">The breakpoint written at the compression cut point.</param>
/// <param name="archivedCount">Number of raw dialogs archived (0 when storage was not compacted).</param>
/// <returns></returns>
Task OnConversationCompressed(string conversationId, ConversationBreakpoint breakpoint, int archivedCount)
=> Task.CompletedTask;
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ Task<bool> SendMessage(string agentId,

Task<string> GetConversationSummary(ConversationSummaryModel model);

/// <summary>
/// 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.
/// </summary>
/// <param name="conversationId"></param>
/// <returns>True when the conversation was compressed.</returns>
Task<bool> AutoCompressIfNeeded(string conversationId) => Task.FromResult(false);

Task<Conversation> GetConversationRecordOrCreateNew(string agentId);

bool IsConversationMode();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,29 @@ public class Conversation

public int DialogCount { get; set; }

/// <summary>
/// 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.
/// </summary>
public string? LastCompactedMessageId { get; set; }

/// <summary>
/// Number of raw dialogs that have been archived out of the hot dialog record by auto-compression.
/// </summary>
public int CompactedDialogCount { get; set; }

/// <summary>
/// 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.
/// </summary>
public bool IsCompressing { get; set; }

/// <summary>
/// When the current compaction was marked in progress. Used to detect and take over a stale flag
/// left behind by a crashed run.
/// </summary>
public DateTime? CompressingStartedTime { get; set; }

public List<string> Tags { get; set; } = [];

public DateTime UpdatedTime { get; set; } = DateTime.UtcNow;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/// <summary>
/// Automatically compress a long conversation once its uncompressed message count grows large,
/// keeping recent turns verbatim and replacing older turns with a generated summary.
/// </summary>
public class AutoCompressionSetting
{
/// <summary>
/// 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.
/// </summary>
public bool Enabled { get; set; } = false;

/// <summary>
/// Enable the storage layer: physically archive old raw dialogs out of the hot dialog record
/// and replace them with a single summary dialog. Requires <see cref="Enabled"/>.
/// </summary>
public bool CompactStorage { get; set; } = false;

/// <summary>
/// Compress when (DialogCount - CompactedDialogCount) reaches this many messages.
/// </summary>
public int TriggerMessageCount { get; set; } = 500;

/// <summary>
/// Number of most recent messages always kept verbatim (never summarized).
/// </summary>
public int KeepRecentCount { get; set; } = 100;

/// <summary>
/// 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.
/// </summary>
public string SummaryAgentId { get; set; } = string.Empty;

/// <summary>
/// Template used to generate the compression summary.
/// </summary>
public string SummaryTemplateName { get; set; } = "conversation.compression";

/// <summary>
/// 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.
/// </summary>
public int CompressionTimeoutSeconds { get; set; } = 300;
}

public class CleanConversationSetting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
Task<List<User>> GetUsersByAffiliateId(string affiliateId) => throw new NotImplementedException();
Task<User?> GetUserByUserName(string userName) => throw new NotImplementedException();
Task UpdateUserName(string userId, string userName) => throw new NotImplementedException();
Task<Dashboard?> GetDashboard(string id = null) => throw new NotImplementedException();

Check warning on line 51 in src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 51 in src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 51 in src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 51 in src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 51 in src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs

View workflow job for this annotation

GitHub Actions / build (macos-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 51 in src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs

View workflow job for this annotation

GitHub Actions / build (macos-latest)

Cannot convert null literal to non-nullable reference type.
Task CreateUser(User user) => throw new NotImplementedException();
Task UpdateExistUser(string userId, User user) => throw new NotImplementedException();
Task UpdateUserVerified(string userId) => throw new NotImplementedException();
Expand Down Expand Up @@ -165,6 +165,27 @@
=> throw new NotImplementedException();
Task<List<string>> TruncateConversation(string conversationId, string messageId, bool cleanLog = false)
=> throw new NotImplementedException();
/// <summary>
/// Compact a conversation's dialog history: archive every dialog before <paramref name="cutMessageId"/>
/// and replace them in the hot dialog record with <paramref name="summaryDialog"/>. Stale versioned
/// state history older than the cut is also trimmed (the latest value of each key is preserved).
/// </summary>
/// <param name="conversationId"></param>
/// <param name="cutMessageId">First message to KEEP; everything strictly before it is archived.</param>
/// <param name="summaryDialog">Summary dialog inserted at the head of the kept messages.</param>
/// <param name="archiveRawDialogs">When true, archived dialogs are retained; otherwise discarded.</param>
/// <returns>Number of dialogs archived, or 0 when nothing was compacted.</returns>
Task<int> CompactConversationDialogs(string conversationId, string cutMessageId, DialogElement summaryDialog, bool archiveRawDialogs = true)
=> throw new NotImplementedException();
/// <summary>
/// Set the conversation's auto-compression flag.
/// When <paramref name="isCompressing"/> is true this is an atomic acquire: it returns true only if the
/// flag was previously off — or was on but older than <paramref name="staleAfter"/> (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.
/// </summary>
Task<bool> UpdateConversationCompressing(string conversationId, bool isCompressing, TimeSpan? staleAfter = null)
=> throw new NotImplementedException();
Task<List<string>> GetConversationStateSearchKeys(ConversationStateKeysFilter filter)
=> throw new NotImplementedException();
Task<List<string>> GetConversationsToMigrate(int batchSize = 100)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, byte> _localCompressing = new();

public async Task<bool> 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<IBotSharpRepository>();

// 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<IConversationHook>(_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 _);
}
Comment on lines +139 to +145

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Compression guard leak 🐞 Bug ☼ Reliability

AutoCompressIfNeeded releases the persisted "IsCompressing" flag before removing the local in-memory
guard; if the repository release call throws, the local guard removal is skipped and this instance
can permanently stop compressing that conversation until restart.
Agent Prompt
### Issue description
In `AutoCompressIfNeeded`, the `finally` block does `await db.UpdateConversationCompressing(..., false)` and only then removes the local guard. If the awaited call throws (I/O/Mongo/network), `_localCompressing.TryRemove` never runs.

### Issue Context
This is a fire-and-forget background task, so failures are plausible (transient DB/file errors). Leaking `_localCompressing` for a conversation means all future attempts on the same instance return early forever.

### Fix Focus Areas
- src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.Compression.cs[139-145]

### Suggested fix
- In the `finally` block, remove the local guard in its own `finally` (or first), and wrap the repository release call in a `try/catch` so cleanup cannot prevent other cleanup.
- Example structure:
  - `try { await db.UpdateConversationCompressing(..., false); } catch (Exception ex) { _logger.LogWarning(ex, ...); } finally { _localCompressing.TryRemove(...); }`

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

/// <summary>
/// Index of the first message to KEEP: aims to keep the last <paramref name="keepRecentCount"/>
/// 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.
/// </summary>
internal static int ComputeCompressionCut(List<RoleDialogModel> 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--;
}
Comment on lines +161 to +165

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Compression cut index crash 🐞 Bug ≡ Correctness

ComputeCompressionCut can produce cutIndex == dialogs.Count when KeepRecentCount <= 0, causing an
IndexOutOfRangeException when accessing dialogs[cutIndex] and making auto-compression fail
repeatedly under misconfiguration.
Agent Prompt
### Issue description
`ComputeCompressionCut` can compute an out-of-range index when `keepRecentCount` is `0` or negative, because it sets `cutIndex = dialogs.Count - Math.Max(0, keepRecentCount)` and then indexes `dialogs[cutIndex]`.

### Issue Context
`KeepRecentCount` is configuration-driven and currently has no validation, so `0`/negative values can enter at runtime.

### Fix Focus Areas
- src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.Compression.cs[154-168]

### Suggested fix
- Treat `keepRecentCount <= 0` as invalid and return `-1` (no compression), **or** clamp to a minimum of `1`.
- Ensure `cutIndex` is always within `[0, dialogs.Count - 1]` before indexing (e.g., early-return when `cutIndex >= dialogs.Count`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


return cutIndex;
}

private async Task<string> SummarizeForCompression(string summaryAgentId, AutoCompressionSetting setting, List<RoleDialogModel> dialogs)
{
var agentService = _services.GetRequiredService<IAgentService>();
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<string> { 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<RoleDialogModel>
{
new RoleDialogModel(AgentRole.User, "Please summarize the conversation above.")
});

return response.Content;
}
}
Loading
Loading