에이전트 루프 반복 준비 단계와 tool_result preview 복원 경로를 안정화한다

- AgentLoopIterationPreparationService를 추가해 queued command 투영, tool_result 대기 요약, query view 생성 책임을 AgentLoopService.RunAsync의 반복 진입부에서 분리함
- AgentMessageInvariantHelper의 preview 스냅샷을 explicit id/fingerprint와 synthetic id로 나눠 저장된 preview 우선, fingerprint 재바인딩 차선, synthetic preview 마지막 순서로 복원하도록 정리함
- AgentToolResultBudget이 source query view 기준 snapshot을 먼저 사용하도록 바꿔 source preview가 local synthetic preview에 가려지지 않게 하고 첫 축약 결과도 source message에 다시 저장함
- AgentToolResultBudgetTests와 AgentLoopIterationPreparationServiceTests를 추가/확장해 같은 tool_result의 장기 세션 재사용과 반복 준비 단계 분리를 회귀로 고정함
- README.md와 docs/DEVELOPMENT.md에 2026-04-15 10:34 (KST) 기준 작업 이력과 검증 명령을 반영함

검증 결과
- 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
This commit is contained in:
2026-04-15 10:35:56 +09:00
parent 2c1926356a
commit 91c4dc74c3
8 changed files with 282 additions and 78 deletions

View File

@@ -7,6 +7,13 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저
개발 참고: Claw Code 동등성 작업 추적 문서 개발 참고: Claw Code 동등성 작업 추적 문서
`docs/claw-code-parity-plan.md` `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 21:25 (KST)
- 업데이트: 2026-04-14 22:52 (KST) - 업데이트: 2026-04-14 22:52 (KST)
- 문서 포맷 고도화를 한 단계 더 진행했습니다. [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)에 `conditional_formats`를 추가해 색상 스케일과 데이터 바 조건부서식을 네이티브 OpenXML로 생성하고, 품질 리뷰에도 반영되도록 했습니다. - 문서 포맷 고도화를 한 단계 더 진행했습니다. [ExcelSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ExcelSkill.cs)에 `conditional_formats`를 추가해 색상 스케일과 데이터 바 조건부서식을 네이티브 OpenXML로 생성하고, 품질 리뷰에도 반영되도록 했습니다.

View File

@@ -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 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 - 검증: `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) 업데이트: 2026-04-14 19:50 (KST)
- Agent loop/queue/context 품질을 보강했습니다. `src/AxCopilot/Services/Agent/AgentCommandQueue.cs`로 실행 중 추가 입력을 우선순위와 interrupt 여부까지 포함해 관리하고, `AgentLoopService`는 이를 안전하게 반영합니다. - Agent loop/queue/context 품질을 보강했습니다. `src/AxCopilot/Services/Agent/AgentCommandQueue.cs`로 실행 중 추가 입력을 우선순위와 interrupt 여부까지 포함해 관리하고, `AgentLoopService`는 이를 안전하게 반영합니다.
- `AgentToolResultBudget`, `AgentQueryContextBuilder`, `ChatModels`는 tool result preview를 메시지에 캐시해 긴 세션과 재질문에서도 같은 축약 결과를 재사용하도록 정리했습니다. - `AgentToolResultBudget`, `AgentQueryContextBuilder`, `ChatModels`는 tool result preview를 메시지에 캐시해 긴 세션과 재질문에서도 같은 축약 결과를 재사용하도록 정리했습니다.

View File

@@ -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<ChatMessage>
{
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");
}
}

View File

@@ -131,4 +131,49 @@ public class AgentToolResultBudgetTests
result.ReusedPreviewCount.Should().BeGreaterThan(0); result.ReusedPreviewCount.Should().BeGreaterThan(0);
queryView[1].QueryPreviewContent.Should().Be(queryView[0].QueryPreviewContent); queryView[1].QueryPreviewContent.Should().Be(queryView[0].QueryPreviewContent);
} }
[Fact]
public void Apply_ShouldReuseFingerprintPreviewFromSourceMessages_WhenToolUseIdChangesAcrossSessions()
{
var longContent = new string('D', 1700);
var sourceMessages = new List<ChatMessage>
{
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<ChatMessage>
{
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");
}
} }

View File

@@ -0,0 +1,59 @@
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
internal sealed record AgentLoopIterationPreparationResult(
AgentQueuedCommandProjectionResult QueueProjection,
AgentQueryContextWindowResult QueryView,
string? ToolResultWaitSummary);
/// <summary>
/// RunAsync 반복 시작 시점의 공통 준비 작업을 묶습니다.
/// queued command 투영, tool_result 대기 진단, query view 생성 책임을 분리해
/// AgentLoopService 본체를 orchestration 중심으로 유지합니다.
/// </summary>
internal static class AgentLoopIterationPreparationService
{
public static AgentLoopIterationPreparationResult Prepare(
List<ChatMessage> 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<ChatMessage> 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;
}
}

View File

@@ -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( WorkflowLogService.LogTransition(
_conversationId, _conversationId,
_currentRunId, _currentRunId,
iteration, iteration,
"llm_wait_after_tool_result", "llm_wait_after_tool_result",
$"{lastToolResultToolName ?? "unknown"}:{waitMs}ms"); iterationPreparation.ToolResultWaitSummary);
lastToolResultAtUtc = null; lastToolResultAtUtc = null;
lastToolResultToolName = null; lastToolResultToolName = null;
} }
// ── 실행 중 사용자 메시지 주입 (Claude Code 스타일 steering) ── foreach (var evt in iterationPreparation.QueueProjection.Events)
DrainPendingCommands(messages); EmitEvent(evt.Type, evt.ToolName, evt.Summary);
var queryView = AgentQueryContextBuilder.Build(messages); var queryView = iterationPreparation.QueryView;
var queryMessages = queryView.Messages; var queryMessages = queryView.Messages;
if (queryView.BoundaryApplied || queryView.ToolPairExpanded || queryView.TruncatedToolResultCount > 0) if (queryView.BoundaryApplied || queryView.ToolPairExpanded || queryView.TruncatedToolResultCount > 0)
{ {

View File

@@ -11,11 +11,27 @@ internal static class AgentMessageInvariantHelper
private const int SyntheticPreviewTextLimit = 280; private const int SyntheticPreviewTextLimit = 280;
private const int PreviewFingerprintTextLimit = 480; private const int PreviewFingerprintTextLimit = 480;
internal sealed record ToolResultPreviewSnapshot(
IReadOnlyDictionary<string, string> ExplicitByToolResultId,
IReadOnlyDictionary<string, string> ExplicitByFingerprint,
IReadOnlyDictionary<string, string> SyntheticByToolResultId);
public static Dictionary<string, string> BuildToolResultPreviewMap(IEnumerable<ChatMessage>? messages) public static Dictionary<string, string> BuildToolResultPreviewMap(IEnumerable<ChatMessage>? messages)
{ {
var previews = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var snapshot = BuildToolResultPreviewSnapshot(messages);
var merged = new Dictionary<string, string>(snapshot.SyntheticByToolResultId, StringComparer.OrdinalIgnoreCase);
foreach (var pair in snapshot.ExplicitByToolResultId)
merged[pair.Key] = pair.Value;
return merged;
}
internal static ToolResultPreviewSnapshot BuildToolResultPreviewSnapshot(IEnumerable<ChatMessage>? messages)
{
var explicitById = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var explicitByFingerprint = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var syntheticById = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (messages == null) if (messages == null)
return previews; return new ToolResultPreviewSnapshot(explicitById, explicitByFingerprint, syntheticById);
var bufferedMessages = messages as IList<ChatMessage> ?? messages.ToList(); var bufferedMessages = messages as IList<ChatMessage> ?? messages.ToList();
@@ -26,20 +42,72 @@ internal static class AgentMessageInvariantHelper
if (!TryGetToolResultId(message, out var toolResultId)) if (!TryGetToolResultId(message, out var toolResultId))
continue; continue;
previews[toolResultId] = message.QueryPreviewContent!; explicitById[toolResultId] = message.QueryPreviewContent!;
if (TryGetToolResultFingerprint(message, out var fingerprint))
explicitByFingerprint[fingerprint] = message.QueryPreviewContent!;
} }
foreach (var message in bufferedMessages) 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; 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)) if (!TryBuildSyntheticToolResultPreview(message, out var preview))
continue; 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<ChatMessage>? messages) public static bool PopulateMissingToolResultPreviews(IList<ChatMessage>? messages)
@@ -47,9 +115,10 @@ internal static class AgentMessageInvariantHelper
if (messages == null || messages.Count == 0) if (messages == null || messages.Count == 0)
return false; return false;
var previews = BuildPersistedToolResultPreviewMap(messages); var snapshot = BuildToolResultPreviewSnapshot(messages);
var previewsByFingerprint = BuildToolResultPreviewFingerprintMap(messages); if (snapshot.ExplicitByToolResultId.Count == 0
if (previews.Count == 0 && previewsByFingerprint.Count == 0) && snapshot.ExplicitByFingerprint.Count == 0
&& snapshot.SyntheticByToolResultId.Count == 0)
return false; return false;
var changed = false; var changed = false;
@@ -57,22 +126,7 @@ internal static class AgentMessageInvariantHelper
{ {
if (!string.IsNullOrWhiteSpace(message.QueryPreviewContent)) if (!string.IsNullOrWhiteSpace(message.QueryPreviewContent))
continue; continue;
if (!TryGetToolResultId(message, out var toolResultId)) if (!TryResolveToolResultPreview(message, snapshot, out var preview))
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))
{ {
continue; continue;
} }
@@ -234,48 +288,6 @@ internal static class AgentMessageInvariantHelper
} }
} }
private static Dictionary<string, string> BuildToolResultPreviewFingerprintMap(IEnumerable<ChatMessage> messages)
{
var previews = new Dictionary<string, string>(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<string, string> BuildPersistedToolResultPreviewMap(IEnumerable<ChatMessage> messages)
{
var previews = new Dictionary<string, string>(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) private static bool TryGetToolResultFingerprint(ChatMessage message, out string fingerprint)
{ {
fingerprint = ""; fingerprint = "";

View File

@@ -27,12 +27,20 @@ public static class AgentToolResultBudget
IReadOnlyList<ChatMessage>? sourceMessages = null) IReadOnlyList<ChatMessage>? sourceMessages = null)
{ {
var result = new AgentToolResultBudgetResult(); var result = new AgentToolResultBudgetResult();
AgentMessageInvariantHelper.PopulateMissingToolResultPreviews(messages); var previewSourceMessages = sourceMessages ?? messages;
if (ReferenceEquals(previewSourceMessages, messages))
{
AgentMessageInvariantHelper.PopulateMissingToolResultPreviews(messages);
}
else if (previewSourceMessages is IList<ChatMessage> sourcePreviewList)
{
AgentMessageInvariantHelper.PopulateMissingToolResultPreviews(sourcePreviewList);
}
var sourceById = sourceMessages? var sourceById = sourceMessages?
.Where(message => !string.IsNullOrWhiteSpace(message.MsgId)) .Where(message => !string.IsNullOrWhiteSpace(message.MsgId))
.ToDictionary(message => message.MsgId, StringComparer.OrdinalIgnoreCase); .ToDictionary(message => message.MsgId, StringComparer.OrdinalIgnoreCase);
var previewSourceMessages = sourceMessages ?? messages; var previewSnapshot = AgentMessageInvariantHelper.BuildToolResultPreviewSnapshot(previewSourceMessages);
var sourcePreviewByToolResultId = AgentMessageInvariantHelper.BuildToolResultPreviewMap(previewSourceMessages);
var nonSystemIndexes = messages var nonSystemIndexes = messages
.Select((message, index) => new { message, index }) .Select((message, index) => new { message, index })
.Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase)) .Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase))
@@ -59,8 +67,7 @@ public static class AgentToolResultBudget
result.TotalCharsBefore += content.Length; result.TotalCharsBefore += content.Length;
if (string.IsNullOrWhiteSpace(message.QueryPreviewContent) if (string.IsNullOrWhiteSpace(message.QueryPreviewContent)
&& AgentMessageInvariantHelper.TryGetToolResultId(message, out var toolResultId) && AgentMessageInvariantHelper.TryResolveToolResultPreview(message, previewSnapshot, out var persistedPreview))
&& sourcePreviewByToolResultId.TryGetValue(toolResultId, out var persistedPreview))
{ {
message.QueryPreviewContent = persistedPreview; message.QueryPreviewContent = persistedPreview;
if (sourceById != null if (sourceById != null