에이전트 루프 반복 준비 단계와 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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user