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);
}
///