diff --git a/Directory.Build.props b/Directory.Build.props index 07763de..ef3b8be 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,7 +9,7 @@ -0.10.28 +0.10.29 diff --git a/src/RockBot.Host/DreamService.cs b/src/RockBot.Host/DreamService.cs index a21ee02..1d2432c 100644 --- a/src/RockBot.Host/DreamService.cs +++ b/src/RockBot.Host/DreamService.cs @@ -432,31 +432,12 @@ private async Task DreamAsync() userMessage.AppendLine($" {e.Content}"); } - var messages = new List - { - new(ChatRole.System, _dreamDirective!), - new(ChatRole.User, userMessage.ToString()) - }; - - ct.ThrowIfCancellationRequested(); - var response = await _llmClient.GetResponseAsync(messages, ModelTier.Balanced, new ChatOptions { ResponseFormat = ChatResponseFormat.Json }, ct); - var raw = response.Text?.Trim() ?? string.Empty; - var json = ExtractJsonObject(raw); - - if (string.IsNullOrEmpty(json)) - { - _logger.LogWarning("DreamService: LLM returned no parseable JSON object; skipping cycle"); - return; - } - - _logger.LogDebug("DreamService: LLM response JSON ({Length} chars): {Json}", json.Length, json); - - var result = TryDeserializeJson(json, "memory dream"); - if (result is null) - { - _logger.LogWarning("DreamService: failed to deserialize dream result; skipping cycle"); - return; - } + var result = await InvokeDreamPassAsync( + "memory dream", + _dreamDirective!, + userMessage.ToString(), + ct); + if (result is null) return; var deleted = 0; var saved = 0; @@ -531,32 +512,32 @@ private async Task DreamAsync() } if (_skillStore is not null) - { ct.ThrowIfCancellationRequested(); await RunSkillGapDetectionPassAsync(); } + { ct.ThrowIfCancellationRequested(); await RunSkillGapDetectionPassAsync(ct); } if (_skillStore is not null) - { ct.ThrowIfCancellationRequested(); await ConsolidateSkillsAsync(); } + { ct.ThrowIfCancellationRequested(); await ConsolidateSkillsAsync(ct); } - ct.ThrowIfCancellationRequested(); await RunEpisodeExtractionPassAsync(); + ct.ThrowIfCancellationRequested(); await RunEpisodeExtractionPassAsync(ct); - ct.ThrowIfCancellationRequested(); await RunEntityExtractionPassAsync(); + ct.ThrowIfCancellationRequested(); await RunEntityExtractionPassAsync(ct); - ct.ThrowIfCancellationRequested(); await RunGraphConsolidationPassAsync(); + ct.ThrowIfCancellationRequested(); await RunGraphConsolidationPassAsync(ct); - ct.ThrowIfCancellationRequested(); await RunMemoryMiningPassAsync(); + ct.ThrowIfCancellationRequested(); await RunMemoryMiningPassAsync(ct); - ct.ThrowIfCancellationRequested(); await RunPreferenceInferencePassAsync(); + ct.ThrowIfCancellationRequested(); await RunPreferenceInferencePassAsync(ct); - ct.ThrowIfCancellationRequested(); await RunSequenceSkillDetectionPassAsync(); + ct.ThrowIfCancellationRequested(); await RunSequenceSkillDetectionPassAsync(ct); - ct.ThrowIfCancellationRequested(); await RunWispFailureAnalysisPassAsync(); + ct.ThrowIfCancellationRequested(); await RunWispFailureAnalysisPassAsync(ct); - ct.ThrowIfCancellationRequested(); await RunToolSuccessLearningPassAsync(); + ct.ThrowIfCancellationRequested(); await RunToolSuccessLearningPassAsync(ct); - ct.ThrowIfCancellationRequested(); await RunTierRoutingReviewPassAsync(); + ct.ThrowIfCancellationRequested(); await RunTierRoutingReviewPassAsync(ct); - ct.ThrowIfCancellationRequested(); await RunDlqReviewPassAsync(); + ct.ThrowIfCancellationRequested(); await RunDlqReviewPassAsync(ct); - ct.ThrowIfCancellationRequested(); await RunIdentityReflectionPassAsync(); + ct.ThrowIfCancellationRequested(); await RunIdentityReflectionPassAsync(ct); sw.Stop(); _logger.LogInformation( @@ -577,7 +558,7 @@ private async Task DreamAsync() } } - private async Task ConsolidateSkillsAsync() + private async Task ConsolidateSkillsAsync(CancellationToken ct) { var all = await _skillStore!.ListAsync(); @@ -709,30 +690,12 @@ private async Task ConsolidateSkillsAsync() } } - var messages = new List - { - new(ChatRole.System, _skillDreamDirective!), - new(ChatRole.User, userMessage.ToString()) - }; - - var response = await _llmClient.GetResponseAsync(messages, ModelTier.Balanced, new ChatOptions { ResponseFormat = ChatResponseFormat.Json }); - var raw = response.Text?.Trim() ?? string.Empty; - var json = ExtractJsonObject(raw); - - if (string.IsNullOrEmpty(json)) - { - _logger.LogWarning("DreamService: skill LLM returned no parseable JSON; skipping skill consolidation"); - return; - } - - _logger.LogDebug("DreamService: skill LLM response JSON ({Length} chars): {Json}", json.Length, json); - - var result = TryDeserializeJson(json, "skill consolidation"); - if (result is null) - { - _logger.LogWarning("DreamService: failed to deserialize skill dream result; skipping"); - return; - } + var result = await InvokeDreamPassAsync( + "skill consolidation", + _skillDreamDirective!, + userMessage.ToString(), + ct); + if (result is null) return; var deleted = 0; var saved = 0; @@ -827,7 +790,7 @@ private async Task ConsolidateSkillsAsync() skill.Name, skill.SeeAlso is { Count: > 0 } ? string.Join(", ", skill.SeeAlso) : "none"); } - await OptimizeSkillsAsync(); + await OptimizeSkillsAsync(ct); _logger.LogInformation( "DreamService: skill consolidation complete — {Deleted} deleted, {Saved} saved", @@ -838,7 +801,7 @@ private async Task ConsolidateSkillsAsync() /// Identifies skills associated with poor-quality sessions and asks the LLM to improve them. /// Skipped when the skill usage store or feedback store is unavailable, or no at-risk skills are found. /// - private async Task OptimizeSkillsAsync() + private async Task OptimizeSkillsAsync(CancellationToken ct) { if (_skillUsageStore is null || _feedbackStore is null || _skillOptimizeDirective is null) return; @@ -1006,28 +969,12 @@ private async Task OptimizeSkillsAsync() } } - var messages = new List - { - new(ChatRole.System, _skillOptimizeDirective), - new(ChatRole.User, userMessage.ToString()) - }; - - var response = await _llmClient.GetResponseAsync(messages, ModelTier.Balanced, new ChatOptions { ResponseFormat = ChatResponseFormat.Json }); - var raw = response.Text?.Trim() ?? string.Empty; - var json = ExtractJsonObject(raw); - - if (string.IsNullOrEmpty(json)) - { - _logger.LogWarning("DreamService: skill optimize LLM returned no parseable JSON; skipping optimization"); - return; - } - - var result = TryDeserializeJson(json, "skill optimization"); - if (result is null) - { - _logger.LogWarning("DreamService: failed to deserialize skill optimize result; skipping"); - return; - } + var result = await InvokeDreamPassAsync( + "skill optimization", + _skillOptimizeDirective, + userMessage.ToString(), + ct); + if (result is null) return; var deleted = 0; var saved = 0; @@ -1095,7 +1042,7 @@ private async Task OptimizeSkillsAsync() /// Runs before skill consolidation so that the consolidation pass can deduplicate /// any new skills alongside existing ones. /// - private async Task RunSkillGapDetectionPassAsync() + private async Task RunSkillGapDetectionPassAsync(CancellationToken ct) { if (_conversationLog is null || _skillStore is null || !_options.SkillGapEnabled) return; @@ -1245,25 +1192,12 @@ private async Task RunSkillGapDetectionPassAsync() } } - var messages = new List - { - new(ChatRole.System, _skillGapDirective ?? BuiltInSkillGapDirective), - new(ChatRole.User, userMessage.ToString()) - }; - - var response = await _llmClient.GetResponseAsync(messages, ModelTier.Balanced, new ChatOptions { ResponseFormat = ChatResponseFormat.Json }); - var raw = response.Text?.Trim() ?? string.Empty; - var json = ExtractJsonObject(raw); - - if (string.IsNullOrEmpty(json)) - { - _logger.LogWarning("DreamService: skill gap LLM returned no parseable JSON; skipping"); - return; - } - - _logger.LogDebug("DreamService: skill gap JSON ({Length} chars): {Json}", json.Length, json); - - var result = TryDeserializeJson(json, "skill gap"); + var result = await InvokeDreamPassAsync( + "skill gap", + _skillGapDirective ?? BuiltInSkillGapDirective, + userMessage.ToString(), + ct); + if (result is null) return; var saved = 0; foreach (var dto in result?.ToSave ?? []) @@ -1422,7 +1356,7 @@ internal async Task RunImportanceDecayPassAsync(IReadOnlyList entri /// Runs before memory mining so episodes exist before facts are distilled. /// Does NOT clear the log — that is deferred to . /// - private async Task RunEpisodeExtractionPassAsync() + private async Task RunEpisodeExtractionPassAsync(CancellationToken ct) { if (_conversationLog is null || !_options.EpisodeExtractionEnabled) return; @@ -1436,7 +1370,7 @@ private async Task RunEpisodeExtractionPassAsync() _logger.LogInformation("DreamService: episode extraction pass — {Count} log entries to analyze", entries.Count); - try + await RunPassAsync("episode extraction", async () => { // Fetch existing episodic memories so the LLM can reinforce them var existingEpisodes = await _memory.SearchAsync( @@ -1471,26 +1405,12 @@ private async Task RunEpisodeExtractionPassAsync() userMessage.AppendLine(); } - var messages = new List - { - new(ChatRole.System, _episodeDirective ?? BuiltInEpisodeDirective), - new(ChatRole.User, userMessage.ToString()) - }; - - var response = await _llmClient.GetResponseAsync(messages, ModelTier.Balanced, - new ChatOptions { ResponseFormat = ChatResponseFormat.Json }); - var raw = response.Text?.Trim() ?? string.Empty; - var json = ExtractJsonObject(raw); - - if (string.IsNullOrEmpty(json)) - { - _logger.LogWarning("DreamService: episode extraction LLM returned no parseable JSON; skipping"); - return; - } - - _logger.LogDebug("DreamService: episode extraction JSON ({Length} chars): {Json}", json.Length, json); - - var result = TryDeserializeJson(json, "episode extraction"); + var result = await InvokeDreamPassAsync( + "episode extraction", + _episodeDirective ?? BuiltInEpisodeDirective, + userMessage.ToString(), + ct); + if (result is null) return; var created = 0; var reinforced = 0; @@ -1578,18 +1498,14 @@ private async Task RunEpisodeExtractionPassAsync() _logger.LogInformation( "DreamService: episode extraction pass complete — {Created} created, {Reinforced} reinforced", created, reinforced); - } - catch (Exception ex) - { - _logger.LogError(ex, "DreamService: episode extraction pass failed"); - } + }); } /// /// Extracts entities and relationships from episodic memories and conversation logs, /// populating the knowledge graph triple store for relational reasoning. /// - private async Task RunEntityExtractionPassAsync() + private async Task RunEntityExtractionPassAsync(CancellationToken ct) { if (_knowledgeGraph is null || !_options.EntityExtractionEnabled) return; @@ -1609,7 +1525,7 @@ private async Task RunEntityExtractionPassAsync() _logger.LogInformation("DreamService: entity extraction pass — {Count} log entries to analyze", entries.Count); - try + await RunPassAsync("entity extraction", async () => { // Provide existing entities so the LLM can reference/update them var existingEntities = await _knowledgeGraph.ListEntitiesAsync(); @@ -1642,26 +1558,12 @@ private async Task RunEntityExtractionPassAsync() userMessage.AppendLine(); } - var messages = new List - { - new(ChatRole.System, _entityExtractionDirective ?? BuiltInEntityExtractionDirective), - new(ChatRole.User, userMessage.ToString()) - }; - - var response = await _llmClient.GetResponseAsync(messages, ModelTier.Balanced, - new ChatOptions { ResponseFormat = ChatResponseFormat.Json }); - var raw = response.Text?.Trim() ?? string.Empty; - var json = ExtractJsonObject(raw); - - if (string.IsNullOrEmpty(json)) - { - _logger.LogWarning("DreamService: entity extraction LLM returned no parseable JSON; skipping"); - return; - } - - _logger.LogDebug("DreamService: entity extraction JSON ({Length} chars): {Json}", json.Length, json); - - var result = TryDeserializeJson(json, "entity extraction"); + var result = await InvokeDreamPassAsync( + "entity extraction", + _entityExtractionDirective ?? BuiltInEntityExtractionDirective, + userMessage.ToString(), + ct); + if (result is null) return; var entitiesCreated = 0; var triplesCreated = 0; @@ -1713,11 +1615,7 @@ private async Task RunEntityExtractionPassAsync() _logger.LogInformation( "DreamService: entity extraction pass complete — {Entities} entities, {Triples} triples created", entitiesCreated, triplesCreated); - } - catch (Exception ex) - { - _logger.LogError(ex, "DreamService: entity extraction pass failed"); - } + }); } /// @@ -1725,7 +1623,7 @@ private async Task RunEntityExtractionPassAsync() /// LLM to decide what to delete or merge. Runs after entity extraction so newly created /// entities are included in the review. /// - private async Task RunGraphConsolidationPassAsync() + private async Task RunGraphConsolidationPassAsync(CancellationToken ct) { if (_knowledgeGraph is null || !_options.GraphConsolidationEnabled) return; @@ -1743,7 +1641,7 @@ private async Task RunGraphConsolidationPassAsync() "DreamService: graph consolidation pass — {Entities} entities, {Triples} triples to review", entities.Count, triples.Count); - try + await RunPassAsync("graph consolidation", async () => { var now = _clock.Now; var userMessage = new StringBuilder(); @@ -1774,26 +1672,12 @@ private async Task RunGraphConsolidationPassAsync() $"- [{t.Id}] {t.Subject} --{t.Predicate}--> {t.Object} (confidence={t.Confidence:F2}, created={t.CreatedAt:yyyy-MM-dd}{source})"); } - var messages = new List - { - new(ChatRole.System, _graphConsolidationDirective ?? BuiltInGraphConsolidationDirective), - new(ChatRole.User, userMessage.ToString()) - }; - - var response = await _llmClient.GetResponseAsync(messages, ModelTier.Balanced, - new ChatOptions { ResponseFormat = ChatResponseFormat.Json }); - var raw = response.Text?.Trim() ?? string.Empty; - var json = ExtractJsonObject(raw); - - if (string.IsNullOrEmpty(json)) - { - _logger.LogWarning("DreamService: graph consolidation LLM returned no parseable JSON; skipping"); - return; - } - - _logger.LogDebug("DreamService: graph consolidation JSON ({Length} chars): {Json}", json.Length, json); - - var result = TryDeserializeJson(json, "graph consolidation"); + var result = await InvokeDreamPassAsync( + "graph consolidation", + _graphConsolidationDirective ?? BuiltInGraphConsolidationDirective, + userMessage.ToString(), + ct); + if (result is null) return; var entitiesDeleted = 0; var triplesDeleted = 0; @@ -1816,11 +1700,7 @@ private async Task RunGraphConsolidationPassAsync() _logger.LogInformation( "DreamService: graph consolidation pass complete — {EntitiesDeleted} entities deleted, {TriplesDeleted} triples deleted", entitiesDeleted, triplesDeleted); - } - catch (Exception ex) - { - _logger.LogError(ex, "DreamService: graph consolidation pass failed"); - } + }); } /// @@ -1829,7 +1709,7 @@ private async Task RunGraphConsolidationPassAsync() /// (which targets behavioral patterns) and skill gap detection (which targets procedures). /// Does NOT clear the log — that is deferred to . /// - private async Task RunMemoryMiningPassAsync() + private async Task RunMemoryMiningPassAsync(CancellationToken ct) { if (_conversationLog is null || !_options.MemoryMiningEnabled) return; @@ -1843,7 +1723,7 @@ private async Task RunMemoryMiningPassAsync() _logger.LogInformation("DreamService: memory mining pass — {Count} log entries to analyze", entries.Count); - try + await RunPassAsync("memory mining", async () => { var userMessage = new StringBuilder(); userMessage.AppendLine("Review the following conversation log for facts worth storing in long-term memory:"); @@ -1863,26 +1743,12 @@ private async Task RunMemoryMiningPassAsync() await AppendSubagentWhiteboardEntriesAsync(userMessage); - var messages = new List - { - new(ChatRole.System, _memoryMiningDirective ?? BuiltInMemoryMiningDirective), - new(ChatRole.User, userMessage.ToString()) - }; - - var response = await _llmClient.GetResponseAsync(messages, ModelTier.Balanced, - new ChatOptions { ResponseFormat = ChatResponseFormat.Json }); - var raw = response.Text?.Trim() ?? string.Empty; - var json = ExtractJsonObject(raw); - - if (string.IsNullOrEmpty(json)) - { - _logger.LogWarning("DreamService: memory mining LLM returned no parseable JSON; skipping"); - return; - } - - _logger.LogDebug("DreamService: memory mining JSON ({Length} chars): {Json}", json.Length, json); - - var result = TryDeserializeJson(json, "memory mining"); + var result = await InvokeDreamPassAsync( + "memory mining", + _memoryMiningDirective ?? BuiltInMemoryMiningDirective, + userMessage.ToString(), + ct); + if (result is null) return; var saved = 0; foreach (var dto in result?.ToSave ?? []) @@ -1909,11 +1775,7 @@ private async Task RunMemoryMiningPassAsync() } _logger.LogInformation("DreamService: memory mining pass complete — {Saved} entry(ies) saved", saved); - } - catch (Exception ex) - { - _logger.LogError(ex, "DreamService: memory mining pass failed"); - } + }); } /// @@ -2216,7 +2078,7 @@ private static string Truncate(string s, int max) => /// memory entries tagged "verified" and "tool-success-learned" so future sessions surface /// them via BM25 or vector recall before the agent has to re-discover the same answer. /// - private async Task RunToolSuccessLearningPassAsync() + private async Task RunToolSuccessLearningPassAsync(CancellationToken ct) { if (!_options.ToolSuccessLearningEnabled || _toolCallLog is null) return; @@ -2237,26 +2099,13 @@ private async Task RunToolSuccessLearningPassAsync() var userMessage = BuildToolSuccessLearningUserMessage(distinctPatterns); - try + await RunPassAsync("tool-success-learning", async () => { - var messages = new List - { - new(ChatRole.System, _toolSuccessLearningDirective ?? BuiltInToolSuccessLearningDirective), - new(ChatRole.User, userMessage) - }; - - var response = await _llmClient.GetResponseAsync(messages, ModelTier.Balanced, - new ChatOptions { ResponseFormat = ChatResponseFormat.Json }); - var raw = response.Text?.Trim() ?? string.Empty; - var json = ExtractJsonObject(raw); - - if (string.IsNullOrEmpty(json)) - { - _logger.LogWarning("DreamService: tool-success-learning LLM returned no parseable JSON; skipping"); - return; - } - - var result = TryDeserializeJson(json, "tool-success-learning"); + var result = await InvokeDreamPassAsync( + "tool-success-learning", + _toolSuccessLearningDirective ?? BuiltInToolSuccessLearningDirective, + userMessage, + ct); var entries = NormalizeToolSuccessLearningEntries( result, idFactory: () => Guid.NewGuid().ToString("N")[..12], @@ -2271,11 +2120,7 @@ private async Task RunToolSuccessLearningPassAsync() _logger.LogInformation( "DreamService: tool-success-learning pass complete — {Saved} entry(ies) saved", entries.Count); - } - catch (Exception ex) - { - _logger.LogError(ex, "DreamService: tool-success-learning pass failed"); - } + }); } /// @@ -2313,7 +2158,7 @@ private async Task AppendSubagentWhiteboardEntriesAsync(StringBuilder userMessag /// and saves inferred preferences as tagged memory entries. /// Always clears the log after the pass to prevent unbounded growth. /// - private async Task RunPreferenceInferencePassAsync() + private async Task RunPreferenceInferencePassAsync(CancellationToken ct) { if (_conversationLog is null || !_options.PreferenceInferenceEnabled) return; @@ -2326,6 +2171,8 @@ private async Task RunPreferenceInferencePassAsync() try { + await RunPassAsync("preference inference", async () => + { // Build user message: turns grouped by session var userMessage = new StringBuilder(); userMessage.AppendLine("Review the following conversation log for durable user preference patterns:"); @@ -2363,28 +2210,16 @@ private async Task RunPreferenceInferencePassAsync() } } - var messages = new List - { - new(ChatRole.System, _prefDreamDirective ?? BuiltInPrefDirective), - new(ChatRole.User, userMessage.ToString()) - }; - - var response = await _llmClient.GetResponseAsync(messages, ModelTier.Balanced, new ChatOptions { ResponseFormat = ChatResponseFormat.Json }); - var raw = response.Text?.Trim() ?? string.Empty; - var json = ExtractJsonObject(raw); - - if (string.IsNullOrEmpty(json)) - { - _logger.LogWarning("DreamService: preference inference LLM returned no parseable JSON; skipping"); - } - else + var result = await InvokeDreamPassAsync( + "preference inference", + _prefDreamDirective ?? BuiltInPrefDirective, + userMessage.ToString(), + ct); + if (result is not null) { - _logger.LogDebug("DreamService: pref inference JSON ({Length} chars): {Json}", json.Length, json); - - var result = TryDeserializeJson(json, "preference inference"); var saved = 0; - foreach (var dto in result?.ToSave ?? []) + foreach (var dto in result.ToSave ?? []) { if (string.IsNullOrWhiteSpace(dto.Content)) continue; @@ -2418,10 +2253,7 @@ private async Task RunPreferenceInferencePassAsync() _logger.LogInformation("DreamService: preference inference pass complete — {Saved} preference(s) inferred", saved); } - } - catch (Exception ex) - { - _logger.LogError(ex, "DreamService: preference inference pass failed"); + }); } finally { @@ -2435,7 +2267,7 @@ private async Task RunPreferenceInferencePassAsync() /// Reviews recent tier-routing decisions and writes an updated tier-selector.json /// when the LLM detects systematic mis-routing. Skipped when fewer than 10 entries exist. /// - private async Task RunTierRoutingReviewPassAsync() + private async Task RunTierRoutingReviewPassAsync(CancellationToken ct) { if (_tierRoutingLogger is null || !_options.TierRoutingReviewEnabled) return; @@ -2492,26 +2324,11 @@ private async Task RunTierRoutingReviewPassAsync() userMessage.AppendLine(await File.ReadAllTextAsync(configPath)); } - var messages = new List - { - new(ChatRole.System, _tierRoutingDirective ?? BuiltInTierRoutingDirective), - new(ChatRole.User, userMessage.ToString()) - }; - - var response = await _llmClient.GetResponseAsync( - messages, ModelTier.Balanced, new ChatOptions { ResponseFormat = ChatResponseFormat.Json }); - var raw = response.Text?.Trim() ?? string.Empty; - var json = ExtractJsonObject(raw); - - if (string.IsNullOrEmpty(json)) - { - _logger.LogWarning("DreamService: tier routing review LLM returned no parseable JSON; skipping"); - return; - } - - _logger.LogDebug("DreamService: tier routing review JSON ({Length} chars): {Json}", json.Length, json); - - var result = TryDeserializeJson(json, "tier routing review"); + var result = await InvokeDreamPassAsync( + "tier routing review", + _tierRoutingDirective ?? BuiltInTierRoutingDirective, + userMessage.ToString(), + ct); if (result is null) return; // Save anti-pattern entries regardless of whether the config changed @@ -2582,12 +2399,12 @@ You are a procedural skill synthesis assistant. Analyze the tool-call sequences /// and synthesize them into reusable skills. Requires and /// to be available. /// - private async Task RunSequenceSkillDetectionPassAsync() + private async Task RunSequenceSkillDetectionPassAsync(CancellationToken ct) { if (_toolCallLog is null || _skillStore is null || !_options.SequenceSkillDetectionEnabled) return; - try + await RunPassAsync("sequence skill detection", async () => { var events = await _toolCallLog.QueryRecentAsync( DateTimeOffset.UtcNow.AddDays(-14), maxResults: 10_000); @@ -2644,26 +2461,12 @@ private async Task RunSequenceSkillDetectionPassAsync() userMessage.AppendLine($" - {name}"); } - var messages = new List - { - new(ChatRole.System, _sequenceSkillDirective ?? BuiltInSequenceSkillDirective), - new(ChatRole.User, userMessage.ToString()) - }; - - var response = await _llmClient.GetResponseAsync( - messages, ModelTier.Balanced, new ChatOptions { ResponseFormat = ChatResponseFormat.Json }); - var raw = response.Text?.Trim() ?? string.Empty; - var json = ExtractJsonObject(raw); - - if (string.IsNullOrEmpty(json)) - { - _logger.LogWarning("DreamService: sequence skill detection LLM returned no parseable JSON; skipping"); - return; - } - - _logger.LogDebug("DreamService: sequence skill detection JSON ({Length} chars): {Json}", json.Length, json); - - var result = TryDeserializeJson(json, "sequence skill detection"); + var result = await InvokeDreamPassAsync( + "sequence skill detection", + _sequenceSkillDirective ?? BuiltInSequenceSkillDirective, + userMessage.ToString(), + ct); + if (result is null) return; var created = 0; foreach (var dto in result?.ToSave ?? []) @@ -2696,11 +2499,7 @@ private async Task RunSequenceSkillDetectionPassAsync() _logger.LogInformation( "DreamService: sequence skill detection pass complete — {Created} skills created from {SessionCount} sessions", created, sessions.Count); - } - catch (Exception ex) - { - _logger.LogError(ex, "DreamService: sequence skill detection pass failed"); - } + }); } // ── Wisp failure analysis ───────────────────────────────────────────── @@ -2745,12 +2544,12 @@ Wisps are lightweight multi-step pipelines with tool invocations. Each record sh /// Analyzes wisp execution records to detect recurring failure patterns and propose /// skill corrections. Requires and . /// - private async Task RunWispFailureAnalysisPassAsync() + private async Task RunWispFailureAnalysisPassAsync(CancellationToken ct) { if (_wispExecutionLog is null || _skillStore is null || !_options.WispFailureAnalysisEnabled) return; - try + await RunPassAsync("wisp failure analysis", async () => { var records = await _wispExecutionLog.QueryRecentAsync( DateTimeOffset.UtcNow.AddDays(-14), maxResults: 500); @@ -2805,26 +2604,12 @@ private async Task RunWispFailureAnalysisPassAsync() userMessage.AppendLine($" - {skill.Name}: {skill.Summary}"); } - var messages = new List - { - new(ChatRole.System, _wispFailureDirective ?? BuiltInWispFailureDirective), - new(ChatRole.User, userMessage.ToString()) - }; - - var response = await _llmClient.GetResponseAsync( - messages, ModelTier.Balanced, new ChatOptions { ResponseFormat = ChatResponseFormat.Json }); - var raw = response.Text?.Trim() ?? string.Empty; - var json = ExtractJsonObject(raw); - - if (string.IsNullOrEmpty(json)) - { - _logger.LogWarning("DreamService: wisp failure analysis LLM returned no parseable JSON; skipping"); - return; - } - - _logger.LogDebug("DreamService: wisp failure analysis JSON ({Length} chars): {Json}", json.Length, json); - - var result = TryDeserializeJson(json, "wisp failure analysis"); + var result = await InvokeDreamPassAsync( + "wisp failure analysis", + _wispFailureDirective ?? BuiltInWispFailureDirective, + userMessage.ToString(), + ct); + if (result is null) return; var updated = 0; // Apply skill updates @@ -2872,11 +2657,7 @@ private async Task RunWispFailureAnalysisPassAsync() _logger.LogInformation( "DreamService: wisp failure analysis pass complete — {Patterns} patterns, {Updates} skill updates, {Candidates} promotion candidates", result?.Patterns?.Count ?? 0, updated, result?.PromotionCandidates?.Count ?? 0); - } - catch (Exception ex) - { - _logger.LogError(ex, "DreamService: wisp failure analysis pass failed"); - } + }); } private sealed record WispFailureAnalysisResultDto @@ -2913,11 +2694,11 @@ private sealed record WispPromotionCandidateDto /// saves patterns as memory entries, and purges queues the LLM deems safe to clear. /// Skipped when the DLQ sampler is unavailable or is false. /// - private async Task RunDlqReviewPassAsync() + private async Task RunDlqReviewPassAsync(CancellationToken ct) { if (_dlqSampler is null || !_options.DlqReviewEnabled) return; - try + await RunPassAsync("DLQ review", async () => { var queues = await _dlqSampler.GetDlqQueuesAsync(); var nonEmpty = queues.Where(q => q.MessageCount > 0).ToList(); @@ -2978,26 +2759,11 @@ private async Task RunDlqReviewPassAsync() } } - var chatMessages = new List - { - new(ChatRole.System, _dlqDirective ?? BuiltInDlqDirective), - new(ChatRole.User, userMessage.ToString()) - }; - - var response = await _llmClient.GetResponseAsync( - chatMessages, ModelTier.Balanced, new ChatOptions { ResponseFormat = ChatResponseFormat.Json }); - var raw = response.Text?.Trim() ?? string.Empty; - var json = ExtractJsonObject(raw); - - if (string.IsNullOrEmpty(json)) - { - _logger.LogWarning("DreamService: DLQ review LLM returned no parseable JSON; skipping"); - return; - } - - _logger.LogDebug("DreamService: DLQ review JSON ({Length} chars): {Json}", json.Length, json); - - var result = TryDeserializeJson(json, "DLQ review"); + var result = await InvokeDreamPassAsync( + "DLQ review", + _dlqDirective ?? BuiltInDlqDirective, + userMessage.ToString(), + ct); if (result is null) return; if (result.NoDlqIssues == true) @@ -3050,11 +2816,7 @@ private async Task RunDlqReviewPassAsync() _logger.LogInformation( "DreamService: DLQ review complete — {Patterns} pattern(s) saved, {Purged} queue(s) purged", savedPatterns, purged); - } - catch (Exception ex) - { - _logger.LogError(ex, "DreamService: DLQ review pass failed"); - } + }); } /// @@ -3062,14 +2824,14 @@ private async Task RunDlqReviewPassAsync() /// under the agent-identity/ memory category. These entries complement the immutable /// soul.md — the pass cannot override core values or boundaries. /// - private async Task RunIdentityReflectionPassAsync() + private async Task RunIdentityReflectionPassAsync(CancellationToken ct) { if (!_options.IdentityReflectionEnabled) return; _logger.LogInformation("DreamService: identity reflection pass — starting"); - try + await RunPassAsync("identity reflection", async () => { // Fetch current identity entries var identityEntries = await _memory.SearchAsync( @@ -3143,26 +2905,11 @@ private async Task RunIdentityReflectionPassAsync() userMessage.AppendLine(); } - var messages = new List - { - new(ChatRole.System, _identityDirective ?? BuiltInIdentityDirective), - new(ChatRole.User, userMessage.ToString()) - }; - - var response = await _llmClient.GetResponseAsync(messages, ModelTier.Balanced, - new ChatOptions { ResponseFormat = ChatResponseFormat.Json }); - var raw = response.Text?.Trim() ?? string.Empty; - var json = ExtractJsonObject(raw); - - if (string.IsNullOrEmpty(json)) - { - _logger.LogWarning("DreamService: identity reflection LLM returned no parseable JSON; skipping"); - return; - } - - _logger.LogDebug("DreamService: identity reflection JSON ({Length} chars): {Json}", json.Length, json); - - var result = TryDeserializeJson(json, "identity reflection"); + var result = await InvokeDreamPassAsync( + "identity reflection", + _identityDirective ?? BuiltInIdentityDirective, + userMessage.ToString(), + ct); if (result is null) return; if (result.NoChange == true) @@ -3217,11 +2964,70 @@ private async Task RunIdentityReflectionPassAsync() _logger.LogInformation( "DreamService: identity reflection pass complete — {Deleted} deleted, {Saved} saved", deleted, saved); + }); + } + + /// + /// Wraps a single dream-pass body so unhandled exceptions become a per-pass + /// error log without aborting the whole cycle. + /// is rethrown so DreamAsync's outer handler can log a single + /// "preempted by user request" line — see issue #333. + /// + private async Task RunPassAsync(string passName, Func body) + { + try + { + await body(); + } + catch (OperationCanceledException) + { + throw; } catch (Exception ex) { - _logger.LogError(ex, "DreamService: identity reflection pass failed"); + _logger.LogError(ex, "DreamService: {Pass} pass failed", passName); + } + } + + /// + /// Runs a single dream pass: builds a System+User chat message pair, calls the + /// Balanced-tier LLM in JSON-response mode with the supplied cancellation token, + /// extracts the outermost JSON object from the response, and deserializes it. + /// Returns null if the LLM produced no parseable JSON or the JSON failed + /// to deserialize into ; in both cases the helper + /// has already logged a warning. The cancellation token MUST be the slot token + /// () so that user preemption interrupts + /// the in-flight LLM call promptly — see issue #333. + /// + private async Task InvokeDreamPassAsync( + string passName, + string systemDirective, + string userMessage, + CancellationToken ct) + where TResult : class + { + var messages = new List + { + new(ChatRole.System, systemDirective), + new(ChatRole.User, userMessage) + }; + + var response = await _llmClient.GetResponseAsync( + messages, + ModelTier.Balanced, + new ChatOptions { ResponseFormat = ChatResponseFormat.Json }, + ct); + + var raw = response.Text?.Trim() ?? string.Empty; + var json = ExtractJsonObject(raw); + if (string.IsNullOrEmpty(json)) + { + _logger.LogWarning("DreamService: {Pass} LLM returned no parseable JSON; skipping", passName); + return null; } + + _logger.LogDebug("DreamService: {Pass} JSON ({Length} chars): {Json}", passName, json.Length, json); + return TryDeserializeJson(json, passName); } ///