에이전트 큐 우선순위 소비와 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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user