diff --git a/README.md b/README.md index 2b3488e..94a40f5 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,13 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-15 10:34 (KST) +- AX Agent 루프의 반복 준비 단계를 분리했습니다. [AgentLoopIterationPreparationService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopIterationPreparationService.cs)가 queued command 투영, tool result 대기 요약, query view 생성 책임을 묶어 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)의 반복 진입부를 더 가볍게 정리합니다. +- 긴 세션과 분기 대화에서 `tool_result` preview가 더 안정적으로 유지되도록 [AgentMessageInvariantHelper.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs)와 [AgentToolResultBudget.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs)를 보강했습니다. 명시적 preview, fingerprint 재바인딩, synthetic preview의 우선순위를 분리해 저장/재개 후에도 사람이 보던 축약 결과를 우선 재사용합니다. +- 테스트: [AgentLoopIterationPreparationServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/AgentLoopIterationPreparationServiceTests.cs), [AgentMessageInvariantHelperTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/AgentMessageInvariantHelperTests.cs), [AgentToolResultBudgetTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs) +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_loop_pipeline\\ -p:IntermediateOutputPath=obj\\verify_loop_pipeline\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentQueuedCommandProjectorTests|AgentLoopIterationPreparationServiceTests|AgentMessageInvariantHelperTests|AgentToolResultBudgetTests|AgentQueryContextBuilderTests|ChatStorageServiceTests" -p:OutputPath=bin\\verify_loop_pipeline_tests\\ -p:IntermediateOutputPath=obj\\verify_loop_pipeline_tests\\` 통과 14 + - 업데이트: 2026-04-14 21:25 (KST) - 업데이트: 2026-04-14 22:52 (KST) - 문서 포맷 고도화를 한 단계 더 진행했습니다. [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)에 `conditional_formats`를 추가해 색상 스케일과 데이터 바 조건부서식을 네이티브 OpenXML로 생성하고, 품질 리뷰에도 반영되도록 했습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5e06b74..7b5c7f4 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -17,6 +17,14 @@ - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_next_doc_ppt\\ -p:IntermediateOutputPath=obj\\verify_next_doc_ppt\\` 경고 0 / 오류 0 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ArtifactQualityReviewServiceTests|ExcelSkillDataValidationTests|ExcelSkillConditionalFormattingTests|ExcelSkillExecutiveSummaryLinkTests|ExcelSkillSummarySheetTests|DocxSkillTemplateFeaturesTests|DocxSkillStyleMapTests|HtmlSkillConsultingSectionsTests|HtmlSkillPrintFrameTests|DocumentAssemblerDocxFeaturesTests|PptxSkillConsultingDeckTests|PptxSkillAutoRepairTests|PptxSkillTemplatePackTests" -p:OutputPath=bin\\verify_next_doc_ppt_tests\\ -p:IntermediateOutputPath=obj\\verify_next_doc_ppt_tests\\` 통과 15 +업데이트: 2026-04-15 10:34 (KST) +- Agent loop 반복 진입부를 분리했습니다. `src/AxCopilot/Services/Agent/AgentLoopIterationPreparationService.cs`를 추가해 queued command 투영, tool_result 대기 요약 생성, `AgentQueryContextBuilder.Build()` 호출을 공통 준비 단계로 묶고, `AgentLoopService.RunAsync()`는 orchestration에 더 집중하도록 정리했습니다. +- `src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs`는 tool result preview 스냅샷을 `ExplicitByToolResultId`, `ExplicitByFingerprint`, `SyntheticByToolResultId`로 분리합니다. 저장된 preview가 있으면 그것을 우선 쓰고, 다른 세션에서 `tool_use_id`가 바뀐 경우에는 fingerprint 재바인딩을 통해 안정적인 preview를 복원하며, 마지막에만 synthetic preview를 사용합니다. +- `src/AxCopilot/Services/Agent/AgentToolResultBudget.cs`는 source query view가 있을 때 source 기준 snapshot을 먼저 만들고, query view에는 그 결과를 재사용하도록 순서를 조정했습니다. 이로써 source 쪽 explicit preview가 local synthetic preview에 가려지지 않고, 첫 축약 결과도 source message에 다시 저장됩니다. +- 테스트: `src/AxCopilot.Tests/Services/AgentLoopIterationPreparationServiceTests.cs`, `src/AxCopilot.Tests/Services/AgentMessageInvariantHelperTests.cs`, `src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs` +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_loop_pipeline\\ -p:IntermediateOutputPath=obj\\verify_loop_pipeline\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentQueuedCommandProjectorTests|AgentLoopIterationPreparationServiceTests|AgentMessageInvariantHelperTests|AgentToolResultBudgetTests|AgentQueryContextBuilderTests|ChatStorageServiceTests" -p:OutputPath=bin\\verify_loop_pipeline_tests\\ -p:IntermediateOutputPath=obj\\verify_loop_pipeline_tests\\` 통과 14 + 업데이트: 2026-04-14 19:50 (KST) - Agent loop/queue/context 품질을 보강했습니다. `src/AxCopilot/Services/Agent/AgentCommandQueue.cs`로 실행 중 추가 입력을 우선순위와 interrupt 여부까지 포함해 관리하고, `AgentLoopService`는 이를 안전하게 반영합니다. - `AgentToolResultBudget`, `AgentQueryContextBuilder`, `ChatModels`는 tool result preview를 메시지에 캐시해 긴 세션과 재질문에서도 같은 축약 결과를 재사용하도록 정리했습니다. diff --git a/src/AxCopilot.Tests/Services/AgentLoopIterationPreparationServiceTests.cs b/src/AxCopilot.Tests/Services/AgentLoopIterationPreparationServiceTests.cs new file mode 100644 index 0000000..7d85c09 --- /dev/null +++ b/src/AxCopilot.Tests/Services/AgentLoopIterationPreparationServiceTests.cs @@ -0,0 +1,61 @@ +using AxCopilot.Models; +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class AgentLoopIterationPreparationServiceTests +{ + [Fact] + public void Prepare_ShouldProjectQueuedCommandsAndBuildUpdatedQueryView() + { + var messages = new List + { + new() + { + MsgId = "assistant-1", + Role = "assistant", + Content = "existing assistant message" + } + }; + + var queue = new AgentCommandQueue(); + queue.EnqueueSteering("focus on the changed files", priority: "now", requestInterrupt: true); + queue.EnqueueNotification("low priority note", priority: "later"); + + var result = AgentLoopIterationPreparationService.Prepare( + messages, + queue, + lastToolResultAtUtc: null, + lastToolResultToolName: null, + utcNow: DateTime.UtcNow); + + messages.Should().Contain(message => + message.MetaKind == "queued_input_interrupt" && + message.Role == "system"); + messages.Should().Contain(message => + message.MetaKind == "queued_steering" && + message.Role == "user" && + message.Content == "focus on the changed files"); + result.QueueProjection.Events.Should().Contain(evt => + evt.Type == AgentEventType.Thinking && + evt.Summary.Contains("Deferred 1 lower-priority", StringComparison.OrdinalIgnoreCase)); + result.QueryView.Messages.Should().Contain(message => + message.MetaKind == "queued_steering" && + message.Role == "user"); + } + + [Fact] + public void BuildToolResultWaitSummary_ShouldFormatToolNameAndElapsedMilliseconds() + { + var utcNow = DateTime.UtcNow; + var waitSummary = AgentLoopIterationPreparationService.BuildToolResultWaitSummary( + utcNow.AddMilliseconds(-1250), + "file_read", + utcNow); + + waitSummary.Should().StartWith("file_read:"); + waitSummary.Should().Contain("ms"); + } +} diff --git a/src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs b/src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs index 202f972..19b0c33 100644 --- a/src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs +++ b/src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs @@ -131,4 +131,49 @@ public class AgentToolResultBudgetTests result.ReusedPreviewCount.Should().BeGreaterThan(0); queryView[1].QueryPreviewContent.Should().Be(queryView[0].QueryPreviewContent); } + + [Fact] + public void Apply_ShouldReuseFingerprintPreviewFromSourceMessages_WhenToolUseIdChangesAcrossSessions() + { + var longContent = new string('D', 1700); + var sourceMessages = new List + { + new() + { + MsgId = "source-tool", + Role = "user", + Content = $$"""{"type":"tool_result","tool_use_id":"call-original","tool_name":"file_read","content":"{{longContent}}"}""", + QueryPreviewContent = """{"type":"tool_result","tool_use_id":"call-original","tool_name":"file_read","content":"preview-fingerprint"}""" + }, + new() + { + MsgId = "tail-1", + Role = "assistant", + Content = "recent tail" + } + }; + + var queryView = new List + { + new() + { + MsgId = "rebuilt-tool", + Role = "user", + Content = $$"""{"type":"tool_result","tool_use_id":"call-replayed","tool_name":"file_read","content":"{{longContent}}"}""" + }, + new() + { + MsgId = "tail-2", + Role = "assistant", + Content = "recent tail" + } + }; + + var result = AgentToolResultBudget.Apply(queryView, protectedRecentNonSystemMessages: 1, sourceMessages: sourceMessages); + + result.ReusedPreviewCount.Should().Be(1); + queryView[0].Content.Should().Contain("call-replayed"); + queryView[0].Content.Should().Contain("preview-fingerprint"); + queryView[0].Content.Should().NotContain("call-original"); + } } diff --git a/src/AxCopilot/Services/Agent/AgentLoopIterationPreparationService.cs b/src/AxCopilot/Services/Agent/AgentLoopIterationPreparationService.cs new file mode 100644 index 0000000..d147224 --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopIterationPreparationService.cs @@ -0,0 +1,59 @@ +using AxCopilot.Models; + +namespace AxCopilot.Services.Agent; + +internal sealed record AgentLoopIterationPreparationResult( + AgentQueuedCommandProjectionResult QueueProjection, + AgentQueryContextWindowResult QueryView, + string? ToolResultWaitSummary); + +/// +/// RunAsync 반복 시작 시점의 공통 준비 작업을 묶습니다. +/// queued command 투영, tool_result 대기 진단, query view 생성 책임을 분리해 +/// AgentLoopService 본체를 orchestration 중심으로 유지합니다. +/// +internal static class AgentLoopIterationPreparationService +{ + public static AgentLoopIterationPreparationResult Prepare( + List messages, + AgentCommandQueue pendingCommands, + DateTime? lastToolResultAtUtc, + string? lastToolResultToolName, + DateTime utcNow) + { + var queueProjection = ProjectQueuedCommands(messages, pendingCommands); + var queryView = AgentQueryContextBuilder.Build(messages); + var waitSummary = BuildToolResultWaitSummary(lastToolResultAtUtc, lastToolResultToolName, utcNow); + + return new AgentLoopIterationPreparationResult(queueProjection, queryView, waitSummary); + } + + public static string? BuildToolResultWaitSummary( + DateTime? lastToolResultAtUtc, + string? lastToolResultToolName, + DateTime utcNow) + { + if (lastToolResultAtUtc is not { } toolResultAt) + return null; + + var waitMs = Math.Max(0, (long)(utcNow - toolResultAt).TotalMilliseconds); + return $"{lastToolResultToolName ?? "unknown"}:{waitMs}ms"; + } + + private static AgentQueuedCommandProjectionResult ProjectQueuedCommands( + List messages, + AgentCommandQueue pendingCommands) + { + var queuedSnapshot = pendingCommands.Snapshot(); + if (queuedSnapshot.Count == 0) + return new AgentQueuedCommandProjectionResult([], []); + + var drained = pendingCommands.DequeuePriorityBatch(); + if (drained.Count == 0) + return new AgentQueuedCommandProjectionResult([], []); + + var projection = AgentQueuedCommandProjector.Project(drained, queuedSnapshot.Count - drained.Count); + messages.AddRange(projection.Messages); + return projection; + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 0c7b07e..d39ca5a 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -451,23 +451,28 @@ public partial class AgentLoopService } } - if (lastToolResultAtUtc is { } toolResultAt) + var iterationPreparation = AgentLoopIterationPreparationService.Prepare( + messages, + _pendingCommands, + lastToolResultAtUtc, + lastToolResultToolName, + DateTime.UtcNow); + if (!string.IsNullOrWhiteSpace(iterationPreparation.ToolResultWaitSummary)) { - var waitMs = Math.Max(0, (long)(DateTime.UtcNow - toolResultAt).TotalMilliseconds); WorkflowLogService.LogTransition( _conversationId, _currentRunId, iteration, "llm_wait_after_tool_result", - $"{lastToolResultToolName ?? "unknown"}:{waitMs}ms"); + iterationPreparation.ToolResultWaitSummary); lastToolResultAtUtc = null; lastToolResultToolName = null; } - // ── 실행 중 사용자 메시지 주입 (Claude Code 스타일 steering) ── - DrainPendingCommands(messages); + foreach (var evt in iterationPreparation.QueueProjection.Events) + EmitEvent(evt.Type, evt.ToolName, evt.Summary); - var queryView = AgentQueryContextBuilder.Build(messages); + var queryView = iterationPreparation.QueryView; var queryMessages = queryView.Messages; if (queryView.BoundaryApplied || queryView.ToolPairExpanded || queryView.TruncatedToolResultCount > 0) { diff --git a/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs b/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs index 7a8a1be..de369e0 100644 --- a/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs +++ b/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs @@ -11,11 +11,27 @@ internal static class AgentMessageInvariantHelper private const int SyntheticPreviewTextLimit = 280; private const int PreviewFingerprintTextLimit = 480; + internal sealed record ToolResultPreviewSnapshot( + IReadOnlyDictionary ExplicitByToolResultId, + IReadOnlyDictionary ExplicitByFingerprint, + IReadOnlyDictionary SyntheticByToolResultId); + public static Dictionary BuildToolResultPreviewMap(IEnumerable? messages) { - var previews = new Dictionary(StringComparer.OrdinalIgnoreCase); + var snapshot = BuildToolResultPreviewSnapshot(messages); + var merged = new Dictionary(snapshot.SyntheticByToolResultId, StringComparer.OrdinalIgnoreCase); + foreach (var pair in snapshot.ExplicitByToolResultId) + merged[pair.Key] = pair.Value; + return merged; + } + + internal static ToolResultPreviewSnapshot BuildToolResultPreviewSnapshot(IEnumerable? messages) + { + var explicitById = new Dictionary(StringComparer.OrdinalIgnoreCase); + var explicitByFingerprint = new Dictionary(StringComparer.OrdinalIgnoreCase); + var syntheticById = new Dictionary(StringComparer.OrdinalIgnoreCase); if (messages == null) - return previews; + return new ToolResultPreviewSnapshot(explicitById, explicitByFingerprint, syntheticById); var bufferedMessages = messages as IList ?? messages.ToList(); @@ -26,20 +42,72 @@ internal static class AgentMessageInvariantHelper if (!TryGetToolResultId(message, out var toolResultId)) continue; - previews[toolResultId] = message.QueryPreviewContent!; + explicitById[toolResultId] = message.QueryPreviewContent!; + if (TryGetToolResultFingerprint(message, out var fingerprint)) + explicitByFingerprint[fingerprint] = message.QueryPreviewContent!; } foreach (var message in bufferedMessages) { - if (!TryGetToolResultId(message, out var toolResultId) || previews.ContainsKey(toolResultId)) + if (!TryGetToolResultId(message, out var toolResultId) + || explicitById.ContainsKey(toolResultId) + || syntheticById.ContainsKey(toolResultId)) continue; + + if (TryGetToolResultFingerprint(message, out var fingerprint) + && explicitByFingerprint.ContainsKey(fingerprint)) + { + // Another message already carries a stable persisted preview for this payload. + // Prefer rebound explicit previews over locally synthesized ones. + continue; + } + if (!TryBuildSyntheticToolResultPreview(message, out var preview)) continue; - previews[toolResultId] = preview; + syntheticById[toolResultId] = preview; } - return previews; + return new ToolResultPreviewSnapshot(explicitById, explicitByFingerprint, syntheticById); + } + + internal static bool TryResolveToolResultPreview( + ChatMessage message, + ToolResultPreviewSnapshot snapshot, + out string preview) + { + preview = ""; + if (!TryGetToolResultId(message, out var toolResultId)) + return false; + + if (snapshot.ExplicitByToolResultId.TryGetValue(toolResultId, out var byIdPreview)) + { + preview = byIdPreview; + return true; + } + + if (TryGetToolResultFingerprint(message, out var fingerprint) + && snapshot.ExplicitByFingerprint.TryGetValue(fingerprint, out var fingerprintPreview)) + { + preview = TryRebindToolResultPreview(fingerprintPreview, toolResultId, message, out var reboundPreview) + ? reboundPreview + : fingerprintPreview; + return true; + } + + if (snapshot.SyntheticByToolResultId.TryGetValue(toolResultId, out var syntheticByIdPreview)) + { + preview = syntheticByIdPreview; + return true; + } + + if (TryBuildSyntheticToolResultPreview(message, out var syntheticPreview)) + { + preview = syntheticPreview; + return true; + } + + return false; } public static bool PopulateMissingToolResultPreviews(IList? messages) @@ -47,9 +115,10 @@ internal static class AgentMessageInvariantHelper if (messages == null || messages.Count == 0) return false; - var previews = BuildPersistedToolResultPreviewMap(messages); - var previewsByFingerprint = BuildToolResultPreviewFingerprintMap(messages); - if (previews.Count == 0 && previewsByFingerprint.Count == 0) + var snapshot = BuildToolResultPreviewSnapshot(messages); + if (snapshot.ExplicitByToolResultId.Count == 0 + && snapshot.ExplicitByFingerprint.Count == 0 + && snapshot.SyntheticByToolResultId.Count == 0) return false; var changed = false; @@ -57,22 +126,7 @@ internal static class AgentMessageInvariantHelper { if (!string.IsNullOrWhiteSpace(message.QueryPreviewContent)) continue; - if (!TryGetToolResultId(message, out var toolResultId)) - continue; - - if (!previews.TryGetValue(toolResultId, out var preview)) - { - if (!TryGetToolResultFingerprint(message, out var fingerprint) - || !previewsByFingerprint.TryGetValue(fingerprint, out var fingerprintPreview)) - { - continue; - } - - preview = TryRebindToolResultPreview(fingerprintPreview, toolResultId, message, out var reboundPreview) - ? reboundPreview - : fingerprintPreview; - } - else if (string.IsNullOrWhiteSpace(preview) && !TryBuildSyntheticToolResultPreview(message, out preview)) + if (!TryResolveToolResultPreview(message, snapshot, out var preview)) { continue; } @@ -234,48 +288,6 @@ internal static class AgentMessageInvariantHelper } } - private static Dictionary BuildToolResultPreviewFingerprintMap(IEnumerable messages) - { - var previews = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var message in messages) - { - if (!TryGetToolResultFingerprint(message, out var fingerprint)) - continue; - - if (!string.IsNullOrWhiteSpace(message.QueryPreviewContent)) - { - previews[fingerprint] = message.QueryPreviewContent!; - continue; - } - - if (previews.ContainsKey(fingerprint)) - continue; - - if (TryBuildSyntheticToolResultPreview(message, out var preview)) - previews[fingerprint] = preview; - } - - return previews; - } - - private static Dictionary BuildPersistedToolResultPreviewMap(IEnumerable messages) - { - var previews = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var message in messages) - { - if (string.IsNullOrWhiteSpace(message.QueryPreviewContent)) - continue; - if (!TryGetToolResultId(message, out var toolResultId)) - continue; - - previews[toolResultId] = message.QueryPreviewContent!; - } - - return previews; - } - private static bool TryGetToolResultFingerprint(ChatMessage message, out string fingerprint) { fingerprint = ""; diff --git a/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs b/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs index 4837440..67f4495 100644 --- a/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs +++ b/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs @@ -27,12 +27,20 @@ public static class AgentToolResultBudget IReadOnlyList? sourceMessages = null) { var result = new AgentToolResultBudgetResult(); - AgentMessageInvariantHelper.PopulateMissingToolResultPreviews(messages); + var previewSourceMessages = sourceMessages ?? messages; + if (ReferenceEquals(previewSourceMessages, messages)) + { + AgentMessageInvariantHelper.PopulateMissingToolResultPreviews(messages); + } + else if (previewSourceMessages is IList sourcePreviewList) + { + AgentMessageInvariantHelper.PopulateMissingToolResultPreviews(sourcePreviewList); + } + var sourceById = sourceMessages? .Where(message => !string.IsNullOrWhiteSpace(message.MsgId)) .ToDictionary(message => message.MsgId, StringComparer.OrdinalIgnoreCase); - var previewSourceMessages = sourceMessages ?? messages; - var sourcePreviewByToolResultId = AgentMessageInvariantHelper.BuildToolResultPreviewMap(previewSourceMessages); + var previewSnapshot = AgentMessageInvariantHelper.BuildToolResultPreviewSnapshot(previewSourceMessages); var nonSystemIndexes = messages .Select((message, index) => new { message, index }) .Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase)) @@ -59,8 +67,7 @@ public static class AgentToolResultBudget result.TotalCharsBefore += content.Length; if (string.IsNullOrWhiteSpace(message.QueryPreviewContent) - && AgentMessageInvariantHelper.TryGetToolResultId(message, out var toolResultId) - && sourcePreviewByToolResultId.TryGetValue(toolResultId, out var persistedPreview)) + && AgentMessageInvariantHelper.TryResolveToolResultPreview(message, previewSnapshot, out var persistedPreview)) { message.QueryPreviewContent = persistedPreview; if (sourceById != null