@@ -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