에이전트 루프 반복 준비 단계와 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

@@ -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);
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(
_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)
{

View File

@@ -11,11 +11,27 @@ internal static class AgentMessageInvariantHelper
private const int SyntheticPreviewTextLimit = 280;
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)
{
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)
return previews;
return new ToolResultPreviewSnapshot(explicitById, explicitByFingerprint, syntheticById);
var bufferedMessages = messages as IList<ChatMessage> ?? 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<ChatMessage>? 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<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)
{
fingerprint = "";

View File

@@ -27,12 +27,20 @@ public static class AgentToolResultBudget
IReadOnlyList<ChatMessage>? 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<ChatMessage> 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