From 07fd2267cb3555a56b543775a378cec6fdd6835d Mon Sep 17 00:00:00 2001 From: lacvet Date: Wed, 15 Apr 2026 07:01:45 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8=20?= =?UTF-8?q?=ED=81=90=20=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84=20=EC=86=8C?= =?UTF-8?q?=EB=B9=84=EC=99=80=20tool=5Fresult=20preview=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목적: - 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" --- README.md | 9 ++ docs/DEVELOPMENT.md | 10 ++ .../Services/AgentCommandQueueTests.cs | 35 ++++++ .../Services/AgentToolResultBudgetTests.cs | 43 +++++++ .../Services/Agent/AgentCommandQueue.cs | 109 +++++++++++++----- .../Services/Agent/AgentLoopService.cs | 17 ++- .../Services/Agent/AgentToolResultBudget.cs | 33 ++++++ 7 files changed, 224 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 7b93752..2e53b20 100644 --- a/README.md +++ b/README.md @@ -1874,3 +1874,12 @@ MIT License - PPT는 품질 리뷰 뒤에 바로 실행 가능한 보정 가이드를 붙이도록 [DeckRepairGuideService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DeckRepairGuideService.cs)를 추가했고, [PptxSkill.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PptxSkill.cs)는 `Deck repair guide:`를 함께 반환합니다. - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_master_batch\\ -p:IntermediateOutputPath=obj\\verify_master_batch\\` 경고 0 / 오류 0 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentCommandQueueTests|CodeLanguageCatalogTests|WorkspaceContextGeneratorTests|PptxSkillConsultingDeckTests|DeckRepairGuideServiceTests" -p:OutputPath=bin\\verify_master_batch_tests\\ -p:IntermediateOutputPath=obj\\verify_master_batch_tests\\` 통과 35 + +업데이트: 2026-04-15 07:00 (KST) +- `claw-code` 기준 남은 격차를 줄이기 위한 통합 고도화 계획을 확정했습니다. 우선순위는 `에이전틱 루프/명령 큐`, `tool_result preview 안정화`, `명령/스킬 합성`, `문서 포맷 마감`, `개발언어 지원 정합화`, `회귀 테스트/문서/릴리즈 게이트` 순서입니다. +- 첫 배치로 [AgentCommandQueue.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentCommandQueue.cs)를 `peek`, `dequeue`, `dequeueAllMatching`, `dequeuePriorityBatch`, `snapshot`까지 지원하는 우선순위 큐로 확장했습니다. 이제 높은 우선순위 입력이 들어온 턴에는 낮은 우선순위 notification을 다음 턴으로 미루어 `claw-code`에 더 가까운 부분 소비 흐름으로 동작합니다. +- [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)는 위 큐를 이용해 한 번에 전체 큐를 비우지 않고, 같은 우선순위 배치만 소비한 뒤 남은 lower-priority 항목을 뒤로 미루도록 조정했습니다. +- [AgentToolResultBudget.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs)는 preview 재사용 기준을 `MsgId`뿐 아니라 `tool_use_id`까지 넓혔습니다. 같은 tool result가 재구성된 메시지로 다시 들어와도 이전 preview를 재사용해 query view 안정성을 높입니다. +- 테스트로 [AgentCommandQueueTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/AgentCommandQueueTests.cs), [AgentToolResultBudgetTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs)를 확장해 `priority batch dequeue`, `predicate matching`, `tool_use_id preview reuse`를 회귀 검증했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_queue_preview\\ -p:IntermediateOutputPath=obj\\verify_queue_preview\\` 경고 0 / 오류 0 +- 검증: `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\\` 통과 7 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 12a41df..712f2c3 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -949,3 +949,13 @@ UI ?붿옄???€洹쒕え 由ы뙥?좊쭅 ???꾪뿕 ?묒뾽 ??湲곕줉???덉쟾 - 테스트: [AgentCommandQueueTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/AgentCommandQueueTests.cs), [CodeLanguageCatalogTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/CodeLanguageCatalogTests.cs), [WorkspaceContextGeneratorTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs), [DeckRepairGuideServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/DeckRepairGuideServiceTests.cs), [PptxSkillConsultingDeckTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/PptxSkillConsultingDeckTests.cs) - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_master_batch\\ -p:IntermediateOutputPath=obj\\verify_master_batch\\` 경고 0 / 오류 0 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentCommandQueueTests|CodeLanguageCatalogTests|WorkspaceContextGeneratorTests|PptxSkillConsultingDeckTests|DeckRepairGuideServiceTests" -p:OutputPath=bin\\verify_master_batch_tests\\ -p:IntermediateOutputPath=obj\\verify_master_batch_tests\\` 통과 35 + +업데이트: 2026-04-15 07:00 (KST) +- `claw-code` 기준 남은 격차를 줄이기 위한 통합 고도화 계획을 확정했습니다. 남은 주요 축은 `에이전틱 루프/명령 큐`, `tool_result preview 안정화`, `명령/스킬 합성`, `문서 포맷 마감`, `개발언어 지원 정합화`, `회귀 테스트/릴리즈 게이트`입니다. +- 첫 배치로 [AgentCommandQueue.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentCommandQueue.cs)를 우선순위 배치 소비가 가능한 구조로 재작성했습니다. `peek`, `dequeue`, `dequeueAllMatching`, `dequeuePriorityBatch`, `snapshot` API를 추가해 `claw-code`의 unified queue처럼 고우선 입력을 먼저 소비하고 lower-priority 항목을 뒤로 미루는 기반을 만들었습니다. +- [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)의 큐 배수 로직도 함께 조정했습니다. 기존 `DrainAll()` 방식 대신 같은 우선순위 배치만 소비하고, 남은 큐 항목이 있으면 `Deferred ... lower-priority queued item(s)` thinking 이벤트를 남겨 다음 턴으로 넘깁니다. +- [AgentToolResultBudget.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs)는 preview 재사용 범위를 넓혔습니다. 기존에는 동일 `MsgId`에서만 `QueryPreviewContent`를 재사용했지만, 이제 `tool_use_id` 기준 preview 인덱스를 만들어 재구성된 tool result 메시지에서도 안정적으로 같은 preview를 재사용합니다. +- 테스트는 [AgentCommandQueueTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/AgentCommandQueueTests.cs)에 `priority batch dequeue`, `predicate matching` 시나리오를 추가했고, [AgentToolResultBudgetTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs)에는 `tool_use_id`가 같은 cloned tool result가 이전 preview를 재사용하는 회귀 케이스를 추가했습니다. +- 다음 배치에서는 `tool_result replacement state`를 대화 단위로 더 고정하고, 이후 `명령/스킬 합성 계층`과 `문서 포맷 마감`으로 순차 확장할 예정입니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_queue_preview\\ -p:IntermediateOutputPath=obj\\verify_queue_preview\\` 경고 0 / 오류 0 +- 검증: `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\\` 통과 7 diff --git a/src/AxCopilot.Tests/Services/AgentCommandQueueTests.cs b/src/AxCopilot.Tests/Services/AgentCommandQueueTests.cs index b1c9770..598150d 100644 --- a/src/AxCopilot.Tests/Services/AgentCommandQueueTests.cs +++ b/src/AxCopilot.Tests/Services/AgentCommandQueueTests.cs @@ -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"); + } } diff --git a/src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs b/src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs index 6528f3b..e4e4844 100644 --- a/src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs +++ b/src/AxCopilot.Tests/Services/AgentToolResultBudgetTests.cs @@ -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 + { + 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 + { + 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); + } } diff --git a/src/AxCopilot/Services/Agent/AgentCommandQueue.cs b/src/AxCopilot/Services/Agent/AgentCommandQueue.cs index dfc6bb2..f4c1e1a 100644 --- a/src/AxCopilot/Services/Agent/AgentCommandQueue.cs +++ b/src/AxCopilot/Services/Agent/AgentCommandQueue.cs @@ -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( /// public sealed class AgentCommandQueue { - private readonly ConcurrentQueue _now = new(); - private readonly ConcurrentQueue _next = new(); - private readonly ConcurrentQueue _later = new(); + private readonly object _gate = new(); + private readonly List _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 DrainAll() + => DequeueAllMatching(static _ => true); + + public IReadOnlyList Snapshot() { - var items = new List(); - 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? predicate = null) + { + lock (_gate) + { + return Order(_items) + .FirstOrDefault(item => predicate == null || predicate(item)); + } + } + + public AgentQueuedCommand? Dequeue(Func? 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 DequeueAllMatching(Func 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 DequeuePriorityBatch(Func? 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 queue, List target) - { - while (queue.TryDequeue(out var item)) - target.Add(item); - } + private static IEnumerable Order(IEnumerable items) + => items + .OrderBy(x => x.Priority) + .ThenBy(x => x.Sequence); private static AgentQueuePriority NormalizePriority(string? priority) => priority?.Trim().ToLowerInvariant() switch diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 22e38ba..477829b 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -89,7 +89,11 @@ public partial class AgentLoopService private void DrainPendingCommands(List 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."); + } } /// 에이전트 이벤트 스트림 (UI 바인딩용). diff --git a/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs b/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs index 6b12129..ad8a98e 100644 --- a/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs +++ b/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs @@ -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 BuildPreviewByToolResultId(IReadOnlyList? sourceMessages) + { + var previews = new Dictionary(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