에이전트 큐 우선순위 소비와 tool_result preview 재사용 안정화

목적:
- claude-code 대비 남아 있던 에이전틱 루프/큐/컨텍스트 격차를 줄이기 위한 1차 배치를 반영한다.
- 낮은 우선순위 알림이 같은 턴에 섞여 들어오던 흐름과 tool_result preview가 MsgId에만 묶여 재사용되던 한계를 개선한다.

핵심 수정:
- AgentCommandQueue를 snapshot/peek/dequeue/dequeueAllMatching/dequeuePriorityBatch를 지원하는 우선순위 큐로 재구성했다.
- AgentLoopService가 전체 큐를 한 번에 비우지 않고 같은 우선순위 배치만 소비하도록 조정했다.
- AgentToolResultBudget가 QueryPreviewContent를 tool_use_id 단위로 재사용하도록 확장해 재구성된 tool_result 메시지에서도 동일 preview를 유지한다.
- AgentCommandQueueTests, AgentToolResultBudgetTests를 확장해 priority batch dequeue, predicate matching, tool_use_id preview reuse를 회귀 검증한다.
- README와 DEVELOPMENT 문서에 2026-04-15 07:00 (KST) 기준 이력과 다음 통합 고도화 계획을 반영했다.

검증:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_queue_preview\ -p:IntermediateOutputPath=obj\verify_queue_preview\
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentCommandQueueTests^|AgentToolResultBudgetTests -p:OutputPath=bin\verify_queue_preview_tests\ -p:IntermediateOutputPath=obj\verify_queue_preview_tests"
This commit is contained in:
2026-04-15 07:01:45 +09:00
parent f33ee7f7db
commit 07fd2267cb
7 changed files with 224 additions and 32 deletions

View File

@@ -52,4 +52,39 @@ public class AgentCommandQueueTests
drained[1].RequestInterrupt.Should().BeTrue();
drained[2].RequestInterrupt.Should().BeTrue();
}
[Fact]
public void DequeuePriorityBatch_ShouldLeaveLowerPriorityItemsQueued()
{
var queue = new AgentCommandQueue();
queue.EnqueueNotification("later-note");
queue.EnqueuePrompt("next-prompt");
queue.EnqueueSteering("now-steer");
var firstBatch = queue.DequeuePriorityBatch();
var secondBatch = queue.DequeuePriorityBatch();
var thirdBatch = queue.DequeuePriorityBatch();
firstBatch.Select(x => x.Content).Should().Equal("now-steer");
secondBatch.Select(x => x.Content).Should().Equal("next-prompt");
thirdBatch.Select(x => x.Content).Should().Equal("later-note");
}
[Fact]
public void PeekAndDequeueAllMatching_ShouldHonorPredicate()
{
var queue = new AgentCommandQueue();
queue.EnqueueNotification("later-note");
queue.EnqueuePrompt("next-prompt");
queue.EnqueueSteering("now-steer");
var peeked = queue.Peek(x => x.Kind == AgentCommandKind.Notification);
var removed = queue.DequeueAllMatching(x => x.Kind == AgentCommandKind.Notification);
var remaining = queue.DrainAll();
peeked.Should().NotBeNull();
peeked!.Content.Should().Be("later-note");
removed.Select(x => x.Content).Should().Equal("later-note");
remaining.Select(x => x.Content).Should().Equal("now-steer", "next-prompt");
}
}

View File

@@ -55,4 +55,47 @@ public class AgentToolResultBudgetTests
second.ReusedPreviewCount.Should().Be(1);
secondWindow[0].Content.Should().Be(sourceMessages[0].QueryPreviewContent);
}
[Fact]
public void Apply_ShouldReusePreviewByToolUseIdAcrossClonedMessages()
{
var longContent = new string('B', 1500);
var sourceMessages = new List<ChatMessage>
{
new()
{
MsgId = "source-tool",
Role = "user",
Content = $$"""{"type":"tool_result","tool_use_id":"call-shared","tool_name":"file_read","content":"{{longContent}}"}""",
QueryPreviewContent = """{"type":"tool_result","tool_use_id":"call-shared","tool_name":"file_read","content":"preview"}"""
},
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-shared","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().Be(sourceMessages[0].QueryPreviewContent);
}
}

View File

@@ -1,5 +1,4 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -38,9 +37,8 @@ public sealed record AgentQueuedCommand(
/// </summary>
public sealed class AgentCommandQueue
{
private readonly ConcurrentQueue<AgentQueuedCommand> _now = new();
private readonly ConcurrentQueue<AgentQueuedCommand> _next = new();
private readonly ConcurrentQueue<AgentQueuedCommand> _later = new();
private readonly object _gate = new();
private readonly List<AgentQueuedCommand> _items = [];
private long _sequence;
public void EnqueuePrompt(string content, string priority = "next", bool requestInterrupt = false)
@@ -63,21 +61,81 @@ public sealed class AgentCommandQueue
public void Clear()
{
while (_now.TryDequeue(out _)) { }
while (_next.TryDequeue(out _)) { }
while (_later.TryDequeue(out _)) { }
lock (_gate)
{
_items.Clear();
}
}
public List<AgentQueuedCommand> DrainAll()
=> DequeueAllMatching(static _ => true);
public IReadOnlyList<AgentQueuedCommand> Snapshot()
{
var items = new List<AgentQueuedCommand>();
DrainInto(_now, items);
DrainInto(_next, items);
DrainInto(_later, items);
return items
.OrderBy(x => x.Priority)
.ThenBy(x => x.Sequence)
.ToList();
lock (_gate)
{
return Order(_items).ToList();
}
}
public AgentQueuedCommand? Peek(Func<AgentQueuedCommand, bool>? predicate = null)
{
lock (_gate)
{
return Order(_items)
.FirstOrDefault(item => predicate == null || predicate(item));
}
}
public AgentQueuedCommand? Dequeue(Func<AgentQueuedCommand, bool>? predicate = null)
{
lock (_gate)
{
var item = Order(_items)
.FirstOrDefault(candidate => predicate == null || predicate(candidate));
if (item == null)
return null;
_items.Remove(item);
return item;
}
}
public List<AgentQueuedCommand> DequeueAllMatching(Func<AgentQueuedCommand, bool> predicate)
{
lock (_gate)
{
var matched = _items.Where(predicate).ToList();
if (matched.Count == 0)
return [];
foreach (var item in matched)
_items.Remove(item);
return Order(matched).ToList();
}
}
public List<AgentQueuedCommand> DequeuePriorityBatch(Func<AgentQueuedCommand, bool>? predicate = null)
{
lock (_gate)
{
var ordered = Order(_items)
.Where(item => predicate == null || predicate(item))
.ToList();
if (ordered.Count == 0)
return [];
var targetPriority = ordered[0].Priority;
var batch = ordered
.Where(item => item.Priority == targetPriority)
.ToList();
foreach (var item in batch)
_items.Remove(item);
return batch;
}
}
private void Enqueue(AgentCommandKind kind, string content, string? priority, bool requestInterrupt)
@@ -93,25 +151,16 @@ public sealed class AgentCommandQueue
DateTime.Now,
requestInterrupt);
switch (item.Priority)
lock (_gate)
{
case AgentQueuePriority.Now:
_now.Enqueue(item);
break;
case AgentQueuePriority.Later:
_later.Enqueue(item);
break;
default:
_next.Enqueue(item);
break;
_items.Add(item);
}
}
private static void DrainInto(ConcurrentQueue<AgentQueuedCommand> queue, List<AgentQueuedCommand> target)
{
while (queue.TryDequeue(out var item))
target.Add(item);
}
private static IEnumerable<AgentQueuedCommand> Order(IEnumerable<AgentQueuedCommand> items)
=> items
.OrderBy(x => x.Priority)
.ThenBy(x => x.Sequence);
private static AgentQueuePriority NormalizePriority(string? priority)
=> priority?.Trim().ToLowerInvariant() switch

View File

@@ -89,7 +89,11 @@ public partial class AgentLoopService
private void DrainPendingCommands(List<ChatMessage> messages)
{
var drained = _pendingCommands.DrainAll();
var queuedSnapshot = _pendingCommands.Snapshot();
if (queuedSnapshot.Count == 0)
return;
var drained = _pendingCommands.DequeuePriorityBatch();
if (drained.Count == 0)
return;
@@ -164,10 +168,19 @@ public partial class AgentLoopService
Content = item.Content,
Timestamp = item.CreatedAt,
});
EmitEvent(AgentEventType.UserMessage, "", item.Content);
EmitEvent(AgentEventType.UserMessage, "", item.Content);
break;
}
}
var deferredCount = queuedSnapshot.Count - drained.Count;
if (deferredCount > 0)
{
EmitEvent(
AgentEventType.Thinking,
"",
$"Deferred {deferredCount} lower-priority queued item(s) for a later turn.");
}
}
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>

View File

@@ -30,6 +30,7 @@ public static class AgentToolResultBudget
var sourceById = sourceMessages?
.Where(message => !string.IsNullOrWhiteSpace(message.MsgId))
.ToDictionary(message => message.MsgId, StringComparer.OrdinalIgnoreCase);
var sourcePreviewByToolResultId = BuildPreviewByToolResultId(sourceMessages);
var nonSystemIndexes = messages
.Select((message, index) => new { message, index })
.Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase))
@@ -55,6 +56,19 @@ public static class AgentToolResultBudget
result.ProcessedCount++;
result.TotalCharsBefore += content.Length;
if (string.IsNullOrWhiteSpace(message.QueryPreviewContent)
&& AgentMessageInvariantHelper.TryGetToolResultId(message, out var toolResultId)
&& sourcePreviewByToolResultId.TryGetValue(toolResultId, out var persistedPreview))
{
message.QueryPreviewContent = persistedPreview;
if (sourceById != null
&& sourceById.TryGetValue(message.MsgId, out var sourceByMessageId)
&& string.IsNullOrWhiteSpace(sourceByMessageId.QueryPreviewContent))
{
sourceByMessageId.QueryPreviewContent = persistedPreview;
}
}
if (!string.IsNullOrWhiteSpace(message.QueryPreviewContent))
{
messages[i] = CloneMessage(message, message.QueryPreviewContent);
@@ -86,6 +100,25 @@ public static class AgentToolResultBudget
return result;
}
private static Dictionary<string, string> BuildPreviewByToolResultId(IReadOnlyList<ChatMessage>? sourceMessages)
{
var previews = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (sourceMessages == null)
return previews;
foreach (var message in sourceMessages)
{
if (string.IsNullOrWhiteSpace(message.QueryPreviewContent))
continue;
if (!AgentMessageInvariantHelper.TryGetToolResultId(message, out var toolResultId))
continue;
previews[toolResultId] = message.QueryPreviewContent!;
}
return previews;
}
public static string TruncateToolResultJson(string json, int softCharLimit = DefaultSoftCharLimit)
{
try