Skip to content

Commit 984e856

Browse files
Stop the dream cycle aborting when memory consolidation returns early (#495)
* Ignore compose agent local config and runtime data deploy/docker-compose/agent-data/ is the bind-mounted runtime directory for the compose agent — it holds the agent profile markdown, the long-term memory store, the knowledge graph, and full conversation transcripts. It was untracked but not ignored, so `git add -A` would stage the entire local agent state (517 files in a working setup) into the repository. Also ignores agent.extra.env, the optional env_file escape hatch for raw .NET config keys, which sits next to the already-ignored .env and can likewise contain provider credentials. Neither belongs in source control: both are per-operator local state. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Stop the dream cycle aborting when memory consolidation returns early The memory consolidation pass ran inline at the top of DreamAsync, and its two early exits — fewer than two stored entries, or a null result from the LLM — used a bare `return`. That returned from the whole dream cycle, not just the pass, so every later pass was silently skipped. The worst case is a freshly provisioned agent. With an empty memory store, consolidation hits the `all.Count < 2` guard on every cycle, so memory mining never runs and the store can never reach two entries. It is a deadlock: the agent cannot mine its first memory, and nothing in the logs says why, because the cycle reports as started but never reports as complete. Extracted the pass into RunMemoryConsolidationPassAsync, returning its deleted and saved counts, so its early exits return from the pass and the cycle continues. Also in this change, both dream-subsystem fixes found alongside it: - Dream:MemoryConsolidationEnabled (default true, so existing deployments are unaffected). Consolidation is the only pass that rewrites stored entries rather than only adding or deleting them, which makes it the only place a dream model can introduce detail no source entry contained. Turning it off costs duplicate merging, importance re-scoring and decay, all of which live inside the pass. - Dream:ModelTier (default Balanced, matching the previous behaviour). One pass was hardcoded to ModelTier.Balanced while the rest of the cycle honoured the configured tier. Dream passes are structured extraction, so being able to point them at a different tier from the conversational one is useful in its own right. Tests cover the toggle default, environment-style binding, and independence from Dream:MemoryMiningEnabled. Full RockBot.Host.Tests suite passes (1080). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 80affc7 commit 984e856

4 files changed

Lines changed: 259 additions & 136 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,7 @@ deploy/values.personal.yaml.live*
1818

1919
# Docker Compose .env files contain API keys — never commit these
2020
deploy/docker-compose/.env
21+
# Local-only raw config overrides for the compose agent (optional env_file)
22+
deploy/docker-compose/agent.extra.env
23+
# Compose agent runtime data (profile, memory, transcripts) — local only, never commit
24+
deploy/docker-compose/agent-data/

src/RockBot.Host.Abstractions/DreamOptions.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ public sealed class DreamOptions
1818
/// </summary>
1919
public string CronSchedule { get; set; } = "0 */12 * * *";
2020

21+
/// <summary>
22+
/// Which LLM tier the dream passes run on. Defaults to <see cref="ModelTier.Balanced"/>,
23+
/// matching the previous hardcoded behaviour.
24+
/// <para>
25+
/// Dream passes are structured-extraction work — read a transcript, return JSON — which is
26+
/// a very different job from whatever the agent does conversationally. Pointing this at a
27+
/// different tier lets an agent run its conversation on a model chosen for voice while
28+
/// consolidating memory on one chosen for instruction-following and reliable JSON.
29+
/// </para>
30+
/// </summary>
31+
public ModelTier ModelTier { get; set; } = ModelTier.Balanced;
32+
2133
/// <summary>
2234
/// Path to the memory consolidation directive file, relative to <see cref="AgentProfileOptions.BasePath"/>.
2335
/// </summary>
@@ -65,6 +77,29 @@ public sealed class DreamOptions
6577
/// </summary>
6678
public string EpisodeDirectivePath { get; set; } = "episode-dream.md";
6779

80+
/// <summary>
81+
/// Whether the memory consolidation pass is enabled — the pass that merges duplicate
82+
/// long-term memory entries, re-scores importance, and prunes ephemeral ones.
83+
/// </summary>
84+
/// <remarks>
85+
/// <para>
86+
/// Worth turning off when entry fidelity matters more than tidiness. Consolidation is the
87+
/// one pass that <em>rewrites</em> stored memories rather than only adding or deleting
88+
/// them, so it is also the only place a weaker or more creative dream model can introduce
89+
/// detail that was never in any source entry. Mining, episode and entity extraction all
90+
/// derive from the conversation log and can be checked against it; a consolidated entry
91+
/// has no such anchor, and any embellishment it picks up is indistinguishable from a real
92+
/// fact on the next pass — which then feeds it back in as its own input.
93+
/// </para>
94+
/// <para>
95+
/// With this off, duplicate and near-duplicate entries accumulate instead of being merged.
96+
/// Importance re-scoring and the decay pass both stop as well, since both run inside
97+
/// consolidation. That is the trade: a larger, redundant store with static importance,
98+
/// whose entries are all traceable to something that was actually said.
99+
/// </para>
100+
/// </remarks>
101+
public bool MemoryConsolidationEnabled { get; set; } = true;
102+
68103
/// <summary>Whether the memory mining pass (requires <see cref="IConversationLog"/>) is enabled.</summary>
69104
public bool MemoryMiningEnabled { get; set; } = true;
70105

src/RockBot.Host/DreamService.cs

Lines changed: 166 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -496,143 +496,18 @@ private async Task DreamAsync()
496496
{
497497
var ct = slot.Token;
498498

499-
// Log retention runs first and unconditionally — append-only JSONL logs
500-
// must be capped even on cycles where there's nothing to consolidate (the
501-
// memory-count check below can early-return).
499+
// Log retention runs first — append-only JSONL logs must be capped even on
500+
// cycles where there is nothing to consolidate.
502501
await RunLogRetentionPassAsync(ct);
503502

504-
var all = await _memory.SearchAsync(new MemorySearchCriteria(MaxResults: 1000));
505-
506-
if (all.Count < 2)
507-
{
508-
_logger.LogInformation(
509-
"DreamService: only {Count} memory entries — nothing to consolidate; skipping",
510-
all.Count);
511-
return;
512-
}
513-
514-
_logger.LogDebug("DreamService: fetched {Count} memory entries for consolidation", all.Count);
515-
516-
// Apply importance decay to unreferenced entries before consolidation
517-
await RunImportanceDecayPassAsync(all);
518-
519-
// Build user message: numbered list with IDs, categories, tags, content
520-
var userMessage = new StringBuilder();
521-
userMessage.AppendLine($"The agent currently has {all.Count} memory entries. Consolidate them:");
522-
userMessage.AppendLine();
523-
524-
// Append recent feedback signals so the dream LLM has quality context
525-
if (_feedbackStore is not null)
526-
{
527-
var recentFeedback = await _feedbackStore.QueryRecentAsync(
528-
since: DateTimeOffset.UtcNow.AddDays(-7),
529-
maxResults: 50);
530-
531-
if (recentFeedback.Count > 0)
532-
{
533-
userMessage.AppendLine();
534-
userMessage.AppendLine("Recent feedback signals (last 7 days):");
535-
foreach (var fb in recentFeedback)
536-
{
537-
var detail = string.IsNullOrWhiteSpace(fb.Detail) ? string.Empty : $" (\"{fb.Detail}\")";
538-
userMessage.AppendLine($"- [{fb.SignalType}] session {fb.SessionId}: {fb.Summary}{detail}");
539-
}
540-
userMessage.AppendLine();
541-
_logger.LogDebug("DreamService: injected {Count} feedback signal(s) into dream prompt", recentFeedback.Count);
542-
}
543-
}
544-
545-
for (var i = 0; i < all.Count; i++)
546-
{
547-
var e = all[i];
548-
var tags = e.Tags.Count > 0 ? string.Join(", ", e.Tags) : "(none)";
549-
var subjectTime = FormatSubjectTimeForPrompt(e.Metadata);
550-
userMessage.AppendLine(
551-
$"{i + 1}. [ID:{e.Id}] category={e.Category ?? "uncategorized"} " +
552-
$"importance={e.ImportanceScore:F2} reinforced={e.ReinforcementCount}× " +
553-
$"first={e.CreatedAt:yyyy-MM-dd} last={e.LastSeenAt:yyyy-MM-dd}" +
554-
$"{subjectTime} tags=[{tags}]");
555-
userMessage.AppendLine($" {e.Content}");
556-
}
557-
558-
var result = await InvokeDreamPassAsync<DreamResultDto>(
559-
"memory dream",
560-
_dreamDirective!,
561-
userMessage.ToString(),
562-
ct);
563-
if (result is null) return;
564-
565-
var deleted = 0;
566-
var saved = 0;
567-
568-
// Union of explicit toDelete IDs and all sourceIds referenced by saved entries.
569-
// This enforces the exhaustive-deletion contract even when the LLM omits some IDs
570-
// from toDelete while still listing them in sourceIds.
571-
var allToDelete = new HashSet<string>(
572-
result.ToDelete ?? [],
573-
StringComparer.OrdinalIgnoreCase);
574-
575-
foreach (var dto in result.ToSave ?? [])
576-
foreach (var srcId in dto.SourceIds ?? [])
577-
allToDelete.Add(srcId);
578-
579-
foreach (var id in allToDelete)
580-
{
581-
await _memory.DeleteAsync(id);
582-
deleted++;
583-
_logger.LogDebug("DreamService: deleted entry {Id}", id);
584-
}
585-
586-
// Build source-entry lookup (case-insensitive on ID) for merge arithmetic
587-
var byId = new Dictionary<string, MemoryEntry>(StringComparer.OrdinalIgnoreCase);
588-
foreach (var e in all)
589-
byId[e.Id] = e;
590-
591-
foreach (var dto in result.ToSave ?? [])
592-
{
593-
if (string.IsNullOrWhiteSpace(dto.Content))
594-
continue;
595-
596-
var sourceIds = dto.SourceIds ?? [];
597-
var sources = sourceIds
598-
.Where(id => byId.ContainsKey(id))
599-
.Select(id => byId[id])
600-
.ToList();
601-
602-
// CreatedAt = earliest source (first-seen preserved); UpdatedAt = now (record rewritten)
603-
var firstSeen = sources.Count > 0 ? sources.Min(s => s.CreatedAt) : DateTimeOffset.UtcNow;
604-
605-
// LastSeenAt = most recent source reinforcement; never "now" on dream housekeeping
606-
var lastSeen = sources.Count > 0 ? sources.Max(s => s.LastSeenAt) : DateTimeOffset.UtcNow;
607-
608-
// ReinforcementCount = sum of source counts; singleton merge (1 source) preserves its count
609-
var reinforcementCount = sources.Count > 0 ? sources.Sum(s => s.ReinforcementCount) : 1;
610-
611-
// LLM-provided importance wins; otherwise carry forward max from sources
612-
var importance = dto.Importance
613-
?? (sources.Count > 0 ? sources.Max(s => s.ImportanceScore) : 0.5f);
614-
615-
var mergedMetadata = MergeSubjectTimeMetadata(sources);
616-
617-
var entry = new MemoryEntry(
618-
Id: Guid.NewGuid().ToString("N")[..12],
619-
Content: dto.Content.Trim(),
620-
Category: string.IsNullOrWhiteSpace(dto.Category) ? null : dto.Category.Trim(),
621-
Tags: dto.Tags ?? [],
622-
CreatedAt: firstSeen,
623-
UpdatedAt: DateTimeOffset.UtcNow,
624-
Metadata: mergedMetadata,
625-
ImportanceScore: Math.Clamp(importance, 0f, 1f))
626-
{
627-
LastSeenAt = lastSeen,
628-
ReinforcementCount = reinforcementCount
629-
};
630-
631-
await _memory.SaveAsync(entry);
632-
saved++;
633-
_logger.LogDebug("DreamService: saved entry {Id} ({Category}, importance={Importance:F2}, reinforced={Count}×): {Content}",
634-
entry.Id, entry.Category ?? "(none)", entry.ImportanceScore, entry.ReinforcementCount, entry.Content);
635-
}
503+
// Consolidation is one pass among many and must not gate the rest of the
504+
// cycle. It lives in its own method precisely so that its early exits (too
505+
// few entries to merge, or a failed LLM call) return from *it* rather than
506+
// aborting the cycle. Inlining it here previously deadlocked a fresh agent:
507+
// consolidation needs >=2 memory entries, memory mining is what creates
508+
// them, and mining runs after consolidation — so an empty store skipped the
509+
// cycle at this point and memory could never become non-empty.
510+
var (deleted, saved) = await RunMemoryConsolidationPassAsync(ct);
636511

637512
if (_skillStore is not null)
638513
{ ct.ThrowIfCancellationRequested(); await RunSkillGapDetectionPassAsync(ct); }
@@ -697,6 +572,161 @@ private async Task DreamAsync()
697572
}
698573
}
699574

575+
/// <summary>
576+
/// Merges and prunes long-term memory entries via the "memory dream" pass.
577+
/// Returns the number of entries deleted and saved.
578+
/// </summary>
579+
/// <remarks>
580+
/// Deliberately a separate method rather than inline in <c>DreamAsync</c>: both of
581+
/// its early exits — fewer than two entries to merge, and a failed LLM call — used
582+
/// to <c>return</c> straight out of the dream cycle, silently skipping every later
583+
/// pass including memory mining. That deadlocked a fresh agent, whose memory store
584+
/// only becomes non-empty once mining has run.
585+
/// </remarks>
586+
private async Task<(int Deleted, int Saved)> RunMemoryConsolidationPassAsync(CancellationToken ct)
587+
{
588+
if (!_options.MemoryConsolidationEnabled)
589+
{
590+
_logger.LogInformation("DreamService: memory consolidation disabled; skipping");
591+
return (0, 0);
592+
}
593+
594+
var all = await _memory.SearchAsync(new MemorySearchCriteria(MaxResults: 1000));
595+
596+
if (all.Count < 2)
597+
{
598+
_logger.LogInformation(
599+
"DreamService: only {Count} memory entries — nothing to consolidate; skipping consolidation",
600+
all.Count);
601+
return (0, 0);
602+
}
603+
604+
_logger.LogDebug("DreamService: fetched {Count} memory entries for consolidation", all.Count);
605+
606+
// Apply importance decay to unreferenced entries before consolidation
607+
await RunImportanceDecayPassAsync(all);
608+
609+
// Build user message: numbered list with IDs, categories, tags, content
610+
var userMessage = new StringBuilder();
611+
userMessage.AppendLine($"The agent currently has {all.Count} memory entries. Consolidate them:");
612+
userMessage.AppendLine();
613+
614+
// Append recent feedback signals so the dream LLM has quality context
615+
if (_feedbackStore is not null)
616+
{
617+
var recentFeedback = await _feedbackStore.QueryRecentAsync(
618+
since: DateTimeOffset.UtcNow.AddDays(-7),
619+
maxResults: 50);
620+
621+
if (recentFeedback.Count > 0)
622+
{
623+
userMessage.AppendLine();
624+
userMessage.AppendLine("Recent feedback signals (last 7 days):");
625+
foreach (var fb in recentFeedback)
626+
{
627+
var detail = string.IsNullOrWhiteSpace(fb.Detail) ? string.Empty : $" (\"{fb.Detail}\")";
628+
userMessage.AppendLine($"- [{fb.SignalType}] session {fb.SessionId}: {fb.Summary}{detail}");
629+
}
630+
userMessage.AppendLine();
631+
_logger.LogDebug("DreamService: injected {Count} feedback signal(s) into dream prompt", recentFeedback.Count);
632+
}
633+
}
634+
635+
for (var i = 0; i < all.Count; i++)
636+
{
637+
var e = all[i];
638+
var tags = e.Tags.Count > 0 ? string.Join(", ", e.Tags) : "(none)";
639+
var subjectTime = FormatSubjectTimeForPrompt(e.Metadata);
640+
userMessage.AppendLine(
641+
$"{i + 1}. [ID:{e.Id}] category={e.Category ?? "uncategorized"} " +
642+
$"importance={e.ImportanceScore:F2} reinforced={e.ReinforcementCount}× " +
643+
$"first={e.CreatedAt:yyyy-MM-dd} last={e.LastSeenAt:yyyy-MM-dd}" +
644+
$"{subjectTime} tags=[{tags}]");
645+
userMessage.AppendLine($" {e.Content}");
646+
}
647+
648+
var result = await InvokeDreamPassAsync<DreamResultDto>(
649+
"memory dream",
650+
_dreamDirective!,
651+
userMessage.ToString(),
652+
ct);
653+
if (result is null) return (0, 0);
654+
655+
var deleted = 0;
656+
var saved = 0;
657+
658+
// Union of explicit toDelete IDs and all sourceIds referenced by saved entries.
659+
// This enforces the exhaustive-deletion contract even when the LLM omits some IDs
660+
// from toDelete while still listing them in sourceIds.
661+
var allToDelete = new HashSet<string>(
662+
result.ToDelete ?? [],
663+
StringComparer.OrdinalIgnoreCase);
664+
665+
foreach (var dto in result.ToSave ?? [])
666+
foreach (var srcId in dto.SourceIds ?? [])
667+
allToDelete.Add(srcId);
668+
669+
foreach (var id in allToDelete)
670+
{
671+
await _memory.DeleteAsync(id);
672+
deleted++;
673+
_logger.LogDebug("DreamService: deleted entry {Id}", id);
674+
}
675+
676+
// Build source-entry lookup (case-insensitive on ID) for merge arithmetic
677+
var byId = new Dictionary<string, MemoryEntry>(StringComparer.OrdinalIgnoreCase);
678+
foreach (var e in all)
679+
byId[e.Id] = e;
680+
681+
foreach (var dto in result.ToSave ?? [])
682+
{
683+
if (string.IsNullOrWhiteSpace(dto.Content))
684+
continue;
685+
686+
var sourceIds = dto.SourceIds ?? [];
687+
var sources = sourceIds
688+
.Where(id => byId.ContainsKey(id))
689+
.Select(id => byId[id])
690+
.ToList();
691+
692+
// CreatedAt = earliest source (first-seen preserved); UpdatedAt = now (record rewritten)
693+
var firstSeen = sources.Count > 0 ? sources.Min(s => s.CreatedAt) : DateTimeOffset.UtcNow;
694+
695+
// LastSeenAt = most recent source reinforcement; never "now" on dream housekeeping
696+
var lastSeen = sources.Count > 0 ? sources.Max(s => s.LastSeenAt) : DateTimeOffset.UtcNow;
697+
698+
// ReinforcementCount = sum of source counts; singleton merge (1 source) preserves its count
699+
var reinforcementCount = sources.Count > 0 ? sources.Sum(s => s.ReinforcementCount) : 1;
700+
701+
// LLM-provided importance wins; otherwise carry forward max from sources
702+
var importance = dto.Importance
703+
?? (sources.Count > 0 ? sources.Max(s => s.ImportanceScore) : 0.5f);
704+
705+
var mergedMetadata = MergeSubjectTimeMetadata(sources);
706+
707+
var entry = new MemoryEntry(
708+
Id: Guid.NewGuid().ToString("N")[..12],
709+
Content: dto.Content.Trim(),
710+
Category: string.IsNullOrWhiteSpace(dto.Category) ? null : dto.Category.Trim(),
711+
Tags: dto.Tags ?? [],
712+
CreatedAt: firstSeen,
713+
UpdatedAt: DateTimeOffset.UtcNow,
714+
Metadata: mergedMetadata,
715+
ImportanceScore: Math.Clamp(importance, 0f, 1f))
716+
{
717+
LastSeenAt = lastSeen,
718+
ReinforcementCount = reinforcementCount
719+
};
720+
721+
await _memory.SaveAsync(entry);
722+
saved++;
723+
_logger.LogDebug("DreamService: saved entry {Id} ({Category}, importance={Importance:F2}, reinforced={Count}×): {Content}",
724+
entry.Id, entry.Category ?? "(none)", entry.ImportanceScore, entry.ReinforcementCount, entry.Content);
725+
}
726+
727+
return (deleted, saved);
728+
}
729+
700730
private async Task ConsolidateSkillsAsync(CancellationToken ct)
701731
{
702732
var all = await _skillStore!.ListAsync();
@@ -3894,7 +3924,7 @@ private async Task RunPassAsync(string passName, Func<Task> body)
38943924

38953925
var response = await _llmClient.GetResponseAsync(
38963926
messages,
3897-
ModelTier.Balanced,
3927+
_options.ModelTier,
38983928
new ChatOptions { ResponseFormat = ChatResponseFormat.Json },
38993929
ct);
39003930

0 commit comments

Comments
 (0)