From f0f1f76f48a0734ce9c055f901551934bc37a956 Mon Sep 17 00:00:00 2001 From: lacvet Date: Thu, 16 Apr 2026 01:59:16 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BD=94=EB=93=9C=ED=83=AD=20tool=20trace=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=20=EC=A0=95=EA=B7=9C=ED=99=94=EC=99=80=20?= =?UTF-8?q?=EC=9D=B8=EC=BD=94=EB=94=A9=20=EC=A0=95=EB=A6=AC=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AgentMessageInvariantHelper에 전송 직전 structured tool trace 정규화 로직을 추가해 missing tool_result assistant와 orphan tool_result를 plain transcript로 평탄화함 - AgentLoopLlmRequestPreparationService에서 query view를 clone한 뒤 normalization을 적용하고 query_context 로그에 tool_trace_repair 메타를 남기도록 확장함 - SessionLearningCollector와 AgentLoopDiagnosticsFormatter의 깨진 문자열과 주석을 영어 기준으로 정리해 active Code 경로의 mojibake 노출을 줄임 - AgentMessageInvariantHelperTests, AgentLoopLlmRequestPreparationServiceTests를 보강하고 dotnet build 및 targeted dotnet test(34 통과, 경고/오류 0)로 검증함 --- README.md | 25 ++ docs/CODE_CONTEXT_RELIABILITY_PLAN.md | 16 ++ docs/DEVELOPMENT.md | 26 +++ ...ntLoopLlmRequestPreparationServiceTests.cs | 30 +++ .../AgentMessageInvariantHelperTests.cs | 60 +++++ .../Agent/AgentLoopContextReliability.cs | 5 +- .../Agent/AgentLoopDiagnosticsFormatter.cs | 4 +- .../AgentLoopLlmRequestPreparationService.cs | 42 +++- .../Agent/AgentMessageInvariantHelper.cs | 216 +++++++++++++++++- .../Agent/SessionLearningCollector.cs | 195 ++++++++-------- 10 files changed, 518 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 551fd02..1e4d1b0 100644 --- a/README.md +++ b/README.md @@ -2389,3 +2389,28 @@ MIT License - 검증: - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_context_reliability_full\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_full\\` 경고 0 / 오류 0 - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentQueryContextBuilderTests|AgentToolResultBudgetTests|AgentLoopIterationPreparationServiceTests|AgentLoopLlmRequestPreparationServiceTests|CodeTaskWorkingSetServiceTests|AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_context_reliability_full_tests\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_full_tests\\` 통과 150 +- 업데이트: 2026-04-16 01:57 (KST) +- Code 탭 컨텍스트 신뢰성 보강 2차 작업을 반영했습니다. + - `src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs` + - 전송 직전 `tool_use/tool_result` 이력을 다시 검사하는 `NormalizeHistoricalToolTrace(...)`를 추가했습니다. + - 결과가 없는 structured assistant tool-call은 plain assistant transcript로 평탄화하고, 대응 assistant가 없는 `tool_result`는 plain user transcript로 바꿔 request payload 자체가 더 일관된 상태로 나가도록 조정했습니다. + - `src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs` + - query view를 그대로 참조하지 않고 deep clone한 뒤 tool-trace normalization을 적용하도록 바꿨습니다. + - supplemental message도 clone해서 원본 대화 이력을 직접 변형하지 않도록 했습니다. + - normalization 결과(`flattened structured assistant`, `converted orphan tool_result`)를 preparation result에 함께 남깁니다. + - `src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs` + - `query_context` workflow log에 `tool_trace_repair=assistants:X/orphan_results:Y`를 추가해, 전송 직전 어떤 보정이 들어갔는지 실행 단위로 추적 가능하게 했습니다. + - `src/AxCopilot/Services/Agent/AgentLoopDiagnosticsFormatter.cs` + - active Code 경로에서 보이던 깨진 컨텍스트 압축 완료 문구를 영어 기준으로 정리했습니다. + - `src/AxCopilot/Services/Agent/SessionLearningCollector.cs` + - 주석과 주입 메시지, 학습 추출 요약 문자열을 영어 기준으로 전면 정리해 인코딩 손상 시에도 깨진 문자열이 다시 노출되지 않게 했습니다. +- 테스트: + - `src/AxCopilot.Tests/Services/AgentMessageInvariantHelperTests.cs` + - missing tool-result가 있는 structured assistant flatten + - orphan `tool_result` plain transcript 변환 + - valid structured tool pair 보존 + - `src/AxCopilot.Tests/Services/AgentLoopLlmRequestPreparationServiceTests.cs` + - request preparation 단계가 broken tool trace를 normalize하면서도 원본 query messages는 보존하는지 검증 +- 검증: + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_tool_trace_hardening\\ -p:IntermediateOutputPath=obj\\verify_tool_trace_hardening\\` 경고 0 / 오류 0 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentMessageInvariantHelperTests|AgentLoopLlmRequestPreparationServiceTests|AgentQueryContextBuilderTests|CodeTaskWorkingSetServiceTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_tool_trace_hardening_tests\\ -p:IntermediateOutputPath=obj\\verify_tool_trace_hardening_tests\\` 통과 34 diff --git a/docs/CODE_CONTEXT_RELIABILITY_PLAN.md b/docs/CODE_CONTEXT_RELIABILITY_PLAN.md index 45936e4..5d8541f 100644 --- a/docs/CODE_CONTEXT_RELIABILITY_PLAN.md +++ b/docs/CODE_CONTEXT_RELIABILITY_PLAN.md @@ -272,3 +272,19 @@ Updated: 2026-04-16 01:41 (KST) - Remaining follow-up: - extend pre-request tool-trace validation so the flattening/orphan repair count trends toward zero rather than being logged after repair - replace more mojibake prompt/status strings in active Code execution paths with English equivalents + +Updated: 2026-04-16 01:57 (KST) + +- Delivered in this pass: + - Phase 4 partial delivery: + - `AgentMessageInvariantHelper.cs` now normalizes historical tool traces before the request leaves the agent loop. + - structured assistant tool-call messages without matching `tool_result` now flatten into plain assistant transcript text. + - orphan `tool_result` messages now flatten into plain user transcript text instead of relying only on late OpenAI payload repair. + - `AgentLoopLlmRequestPreparationService.cs` clones the query window first, then applies normalization, so request cleanup does not mutate stored conversation history. + - `AgentLoopContextReliability.cs` now logs tool-trace repair counts inside the `query_context` transition for run-by-run observability. + - Phase 5 partial delivery: + - `SessionLearningCollector.cs` was rewritten with English-only comments and English injection text. + - `AgentLoopDiagnosticsFormatter.cs` no longer emits mojibake-prone compaction status text in active Code paths. +- Remaining follow-up: + - measure whether `tool_trace_repair` counts keep trending down in long Code runs after this preflight normalization + - continue replacing older mojibake strings outside the active Code execution path diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 0391993..caec3fd 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1804,3 +1804,29 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫 - 검증: - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_context_reliability_full\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_full\\` 경고 0 / 오류 0 - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentQueryContextBuilderTests|AgentToolResultBudgetTests|AgentLoopIterationPreparationServiceTests|AgentLoopLlmRequestPreparationServiceTests|CodeTaskWorkingSetServiceTests|AgentLoopCodeQualityTests" -p:OutputPath=bin\\verify_context_reliability_full_tests\\ -p:IntermediateOutputPath=obj\\verify_context_reliability_full_tests\\` 통과 150 + +업데이트: 2026-04-16 01:57 (KST) +- Code 탭 컨텍스트 신뢰성 보강 2차 작업을 적용했습니다. + - `src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs` + - `NormalizeHistoricalToolTrace(...)`를 추가했습니다. + - 결과가 없는 structured assistant tool-call은 plain assistant transcript로 바꾸고, 대응 assistant가 없는 `tool_result`는 plain user transcript로 바꿔 request payload가 더 일관된 상태로 전송되도록 조정했습니다. + - `src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs` + - query view를 deep clone한 뒤 normalization을 적용하도록 변경했습니다. + - supplemental messages도 clone해서 원본 대화 이력 오염 없이 request 직전 정리를 수행합니다. + - preparation result에 `FlattenedStructuredAssistantCount`, `ConvertedOrphanToolResultCount`를 추가했습니다. + - `src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs` + - `query_context` workflow log에 `tool_trace_repair=assistants:X/orphan_results:Y`를 남기도록 확장했습니다. + - `src/AxCopilot/Services/Agent/AgentLoopDiagnosticsFormatter.cs` + - active Code 경로에서 보이던 깨진 compaction 상태 문자열을 영어 기준으로 정리했습니다. + - `src/AxCopilot/Services/Agent/SessionLearningCollector.cs` + - 영어 주석 기준으로 전면 정리했고, 학습 주입 메시지와 추출 요약 문자열도 영어 기준으로 통일했습니다. +- 테스트: + - `src/AxCopilot.Tests/Services/AgentMessageInvariantHelperTests.cs` + - missing tool-result가 있는 structured assistant flatten + - orphan `tool_result` plain transcript 변환 + - valid tool pair 보존 + - `src/AxCopilot.Tests/Services/AgentLoopLlmRequestPreparationServiceTests.cs` + - preparation 단계 normalization 적용과 원본 query message 보존을 검증 +- 검증: + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_tool_trace_hardening\\ -p:IntermediateOutputPath=obj\\verify_tool_trace_hardening\\` 경고 0 / 오류 0 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentMessageInvariantHelperTests|AgentLoopLlmRequestPreparationServiceTests|AgentQueryContextBuilderTests|CodeTaskWorkingSetServiceTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_tool_trace_hardening_tests\\ -p:IntermediateOutputPath=obj\\verify_tool_trace_hardening_tests\\` 통과 34 diff --git a/src/AxCopilot.Tests/Services/AgentLoopLlmRequestPreparationServiceTests.cs b/src/AxCopilot.Tests/Services/AgentLoopLlmRequestPreparationServiceTests.cs index 1f01a90..2504d17 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopLlmRequestPreparationServiceTests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopLlmRequestPreparationServiceTests.cs @@ -86,4 +86,34 @@ public class AgentLoopLlmRequestPreparationServiceTests result.SendMessages[1].MetaKind.Should().Be("code_working_set"); result.SendMessages.Last().Content.Should().Contain("[TOOL_REQUIRED]"); } + + [Fact] + public void Prepare_ShouldNormalizeBrokenHistoricalToolTraceBeforeSend() + { + var queryMessages = new List + { + new() + { + Role = "assistant", + Content = """{"_tool_use_blocks":[{"type":"text","text":"Inspecting source"},{"type":"tool_use","id":"call-missing","name":"file_read","input":{"path":"App.xaml"}}]}""" + }, + new() + { + Role = "user", + Content = "fix the latest build failure" + } + }; + + var result = AgentLoopLlmRequestPreparationService.Prepare( + queryMessages, + totalToolCalls: 1, + forceInitialToolCallEnabled: true, + injectPreCallToolReminder: true, + noToolCallLoopRetry: 0); + + result.FlattenedStructuredAssistantCount.Should().Be(1); + result.ConvertedOrphanToolResultCount.Should().Be(0); + result.SendMessages[0].Content.Should().Be("Inspecting source\n[previous tool call] file_read"); + queryMessages[0].Content.Should().StartWith("{\"_tool_use_blocks\""); + } } diff --git a/src/AxCopilot.Tests/Services/AgentMessageInvariantHelperTests.cs b/src/AxCopilot.Tests/Services/AgentMessageInvariantHelperTests.cs index 68b40e8..7114a37 100644 --- a/src/AxCopilot.Tests/Services/AgentMessageInvariantHelperTests.cs +++ b/src/AxCopilot.Tests/Services/AgentMessageInvariantHelperTests.cs @@ -7,6 +7,66 @@ namespace AxCopilot.Tests.Services; public class AgentMessageInvariantHelperTests { + [Fact] + public void NormalizeHistoricalToolTrace_ShouldFlattenAssistantWithMissingToolResult() + { + var messages = new List + { + new() + { + Role = "assistant", + Content = """{"_tool_use_blocks":[{"type":"text","text":"Reviewing the project"},{"type":"tool_use","id":"call-missing","name":"file_read","input":{"path":"App.xaml"}}]}""" + } + }; + + var result = AgentMessageInvariantHelper.NormalizeHistoricalToolTrace(messages); + + result.FlattenedAssistantCount.Should().Be(1); + messages[0].Content.Should().Be("Reviewing the project\n[previous tool call] file_read"); + } + + [Fact] + public void NormalizeHistoricalToolTrace_ShouldConvertOrphanToolResultToPlainUserTranscript() + { + var messages = new List + { + new() + { + Role = "user", + Content = """{"type":"tool_result","tool_use_id":"call-orphan","tool_name":"file_read","content":"line1\nline2"}""" + } + }; + + var result = AgentMessageInvariantHelper.NormalizeHistoricalToolTrace(messages); + + result.ConvertedOrphanToolResultCount.Should().Be(1); + messages[0].Content.Should().Be("[previous tool result: file_read]\nline1 line2"); + } + + [Fact] + public void NormalizeHistoricalToolTrace_ShouldPreserveValidStructuredToolPair() + { + var messages = new List + { + new() + { + Role = "assistant", + Content = """{"_tool_use_blocks":[{"type":"text","text":"Reading source"},{"type":"tool_use","id":"call-valid","name":"file_read","input":{"path":"App.xaml"}}]}""" + }, + new() + { + Role = "user", + Content = """{"type":"tool_result","tool_use_id":"call-valid","tool_name":"file_read","content":"file content"}""" + } + }; + + var result = AgentMessageInvariantHelper.NormalizeHistoricalToolTrace(messages); + + result.TotalAdjustedCount.Should().Be(0); + messages[0].Content.Should().StartWith("{\"_tool_use_blocks\""); + messages[1].Content.Should().StartWith("{\"type\":\"tool_result\""); + } + [Fact] public void PopulateMissingToolResultPreviews_ShouldSynthesizePreview_WhenNoStoredPreviewExists() { diff --git a/src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs b/src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs index 040714a..ff11a53 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs @@ -14,6 +14,9 @@ public partial class AgentLoopService { var estimatedSendTokens = TokenEstimator.EstimateMessages(llmRequest.SendMessages); var workingSetSummary = codeWorkingSet?.DescribeForLog() ?? "none"; - return $"{AgentLoopDiagnosticsFormatter.BuildQueryViewSummary(queryView)}, profile={queryView.ProfileName}, protected_recent={queryView.ProtectedRecentNonSystemMessages}, send={llmRequest.SendMessages.Count}, send_tokens={estimatedSendTokens}, supplemental={llmRequest.SupplementalMessageCount}, tool_reminder={llmRequest.InjectedToolReminder}, working_set={workingSetSummary}"; + var toolTraceRepair = llmRequest.FlattenedStructuredAssistantCount > 0 || llmRequest.ConvertedOrphanToolResultCount > 0 + ? $"tool_trace_repair=assistants:{llmRequest.FlattenedStructuredAssistantCount}/orphan_results:{llmRequest.ConvertedOrphanToolResultCount}, " + : ""; + return $"{AgentLoopDiagnosticsFormatter.BuildQueryViewSummary(queryView)}, profile={queryView.ProfileName}, protected_recent={queryView.ProtectedRecentNonSystemMessages}, send={llmRequest.SendMessages.Count}, send_tokens={estimatedSendTokens}, supplemental={llmRequest.SupplementalMessageCount}, {toolTraceRepair}tool_reminder={llmRequest.InjectedToolReminder}, working_set={workingSetSummary}"; } } diff --git a/src/AxCopilot/Services/Agent/AgentLoopDiagnosticsFormatter.cs b/src/AxCopilot/Services/Agent/AgentLoopDiagnosticsFormatter.cs index f9927f3..69887d6 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopDiagnosticsFormatter.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopDiagnosticsFormatter.cs @@ -6,8 +6,8 @@ internal static class AgentLoopDiagnosticsFormatter { var compactSummary = !string.IsNullOrWhiteSpace(result.StageSummary) ? result.StageSummary - : "기본"; - return $"컨텍스트 압축 완료 — {compactSummary} · {Services.TokenEstimator.Format(result.SavedTokens)} tokens 절감"; + : "default"; + return $"Context compaction complete: {compactSummary}, saved {Services.TokenEstimator.Format(result.SavedTokens)} tokens"; } public static string BuildQueryViewSummary(AgentQueryContextWindowResult queryView) diff --git a/src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs b/src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs index 76b93bd..183ec15 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopLlmRequestPreparationService.cs @@ -6,7 +6,9 @@ internal sealed record AgentLoopLlmRequestPreparationResult( List SendMessages, bool ForceInitialToolCall, bool InjectedToolReminder, - int SupplementalMessageCount); + int SupplementalMessageCount, + int FlattenedStructuredAssistantCount, + int ConvertedOrphanToolResultCount); /// /// Builds the final LLM request array from the query window and optional @@ -22,7 +24,8 @@ internal static class AgentLoopLlmRequestPreparationService int noToolCallLoopRetry, IEnumerable? supplementalMessages = null) { - var sendMessages = queryMessages.ToList(); + var sendMessages = queryMessages.Select(CloneMessage).ToList(); + var normalization = AgentMessageInvariantHelper.NormalizeHistoricalToolTrace(sendMessages); var supplementalCount = AppendSupplementalMessages(sendMessages, supplementalMessages); var forceInitialToolCall = totalToolCalls == 0 && forceInitialToolCallEnabled; if (!forceInitialToolCall @@ -33,7 +36,9 @@ internal static class AgentLoopLlmRequestPreparationService sendMessages, forceInitialToolCall, false, - supplementalCount); + supplementalCount, + normalization.FlattenedAssistantCount, + normalization.ConvertedOrphanToolResultCount); } sendMessages.Add(BuildToolReminderMessage()); @@ -41,7 +46,9 @@ internal static class AgentLoopLlmRequestPreparationService sendMessages, forceInitialToolCall, true, - supplementalCount); + supplementalCount, + normalization.FlattenedAssistantCount, + normalization.ConvertedOrphanToolResultCount); } internal static ChatMessage BuildToolReminderMessage() @@ -65,10 +72,35 @@ internal static class AgentLoopLlmRequestPreparationService if (message == null || string.IsNullOrWhiteSpace(message.Content)) continue; - sendMessages.Add(message); + sendMessages.Add(CloneMessage(message)); added++; } return added; } + + private static ChatMessage CloneMessage(ChatMessage source) + { + return new ChatMessage + { + MsgId = source.MsgId, + Role = source.Role, + Content = source.Content, + Timestamp = source.Timestamp, + MetaKind = source.MetaKind, + MetaRunId = source.MetaRunId, + Feedback = source.Feedback, + ResponseElapsedMs = source.ResponseElapsedMs, + PromptTokens = source.PromptTokens, + CompletionTokens = source.CompletionTokens, + AttachedFiles = source.AttachedFiles?.ToList(), + QueryPreviewContent = source.QueryPreviewContent, + Images = source.Images?.Select(image => new ImageAttachment + { + Base64 = image.Base64, + MimeType = image.MimeType, + FileName = image.FileName, + }).ToList(), + }; + } } diff --git a/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs b/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs index de369e0..8e25500 100644 --- a/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs +++ b/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs @@ -4,7 +4,7 @@ using AxCopilot.Models; namespace AxCopilot.Services.Agent; /// -/// tool_use / tool_result 불변식을 유지하기 위한 공용 helper입니다. +/// Shared helpers for preserving valid tool-use / tool-result history. /// internal static class AgentMessageInvariantHelper { @@ -16,6 +16,19 @@ internal static class AgentMessageInvariantHelper IReadOnlyDictionary ExplicitByFingerprint, IReadOnlyDictionary SyntheticByToolResultId); + internal sealed record StructuredAssistantToolUseSnapshot( + int MessageIndex, + string AssistantText, + IReadOnlyList ToolNames, + IReadOnlyList ToolIds); + + public sealed record HistoricalToolTraceNormalizationResult( + int FlattenedAssistantCount, + int ConvertedOrphanToolResultCount) + { + public int TotalAdjustedCount => FlattenedAssistantCount + ConvertedOrphanToolResultCount; + } + public static Dictionary BuildToolResultPreviewMap(IEnumerable? messages) { var snapshot = BuildToolResultPreviewSnapshot(messages); @@ -52,7 +65,9 @@ internal static class AgentMessageInvariantHelper if (!TryGetToolResultId(message, out var toolResultId) || explicitById.ContainsKey(toolResultId) || syntheticById.ContainsKey(toolResultId)) + { continue; + } if (TryGetToolResultFingerprint(message, out var fingerprint) && explicitByFingerprint.ContainsKey(fingerprint)) @@ -71,6 +86,67 @@ internal static class AgentMessageInvariantHelper return new ToolResultPreviewSnapshot(explicitById, explicitByFingerprint, syntheticById); } + public static HistoricalToolTraceNormalizationResult NormalizeHistoricalToolTrace(IList? messages) + { + if (messages == null || messages.Count == 0) + return new HistoricalToolTraceNormalizationResult(0, 0); + + var structuredAssistants = new List(); + var assistantIndexByToolUseId = new Dictionary(StringComparer.OrdinalIgnoreCase); + var toolResultIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < messages.Count; i++) + { + if (TryParseStructuredAssistantToolUse(messages[i], i, out var assistantSnapshot)) + { + structuredAssistants.Add(assistantSnapshot); + foreach (var toolUseId in assistantSnapshot.ToolIds) + { + if (!assistantIndexByToolUseId.ContainsKey(toolUseId)) + assistantIndexByToolUseId[toolUseId] = i; + } + } + + if (TryGetToolResultId(messages[i], out var toolResultId)) + toolResultIds.Add(toolResultId); + } + + var flattenedAssistantCount = 0; + var flattenedToolUseIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var assistant in structuredAssistants) + { + if (assistant.ToolIds.Count == 0 + || assistant.ToolIds.All(toolResultIds.Contains)) + { + continue; + } + + messages[assistant.MessageIndex].Content = BuildAssistantToolTranscript(assistant.AssistantText, assistant.ToolNames); + messages[assistant.MessageIndex].QueryPreviewContent = null; + flattenedAssistantCount++; + foreach (var toolUseId in assistant.ToolIds) + flattenedToolUseIds.Add(toolUseId); + } + + var convertedOrphanToolResultCount = 0; + for (var i = 0; i < messages.Count; i++) + { + if (!TryGetToolResultId(messages[i], out var toolUseId)) + continue; + + var hasStructuredAssistant = assistantIndexByToolUseId.ContainsKey(toolUseId); + var wasFlattened = flattenedToolUseIds.Contains(toolUseId); + if (hasStructuredAssistant && !wasFlattened) + continue; + + messages[i].Content = BuildToolResultTranscript(messages[i]); + messages[i].QueryPreviewContent = null; + convertedOrphanToolResultCount++; + } + + return new HistoricalToolTraceNormalizationResult(flattenedAssistantCount, convertedOrphanToolResultCount); + } + internal static bool TryResolveToolResultPreview( ChatMessage message, ToolResultPreviewSnapshot snapshot, @@ -119,7 +195,9 @@ internal static class AgentMessageInvariantHelper if (snapshot.ExplicitByToolResultId.Count == 0 && snapshot.ExplicitByFingerprint.Count == 0 && snapshot.SyntheticByToolResultId.Count == 0) + { return false; + } var changed = false; foreach (var message in messages) @@ -127,9 +205,7 @@ internal static class AgentMessageInvariantHelper if (!string.IsNullOrWhiteSpace(message.QueryPreviewContent)) continue; if (!TryResolveToolResultPreview(message, snapshot, out var preview)) - { continue; - } message.QueryPreviewContent = preview; changed = true; @@ -382,6 +458,140 @@ internal static class AgentMessageInvariantHelper } } + private static bool TryParseStructuredAssistantToolUse( + ChatMessage message, + int messageIndex, + out StructuredAssistantToolUseSnapshot snapshot) + { + snapshot = null!; + if (!string.Equals(message.Role, "assistant", StringComparison.OrdinalIgnoreCase)) + return false; + + var content = message.Content ?? ""; + if (!content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal)) + return false; + + try + { + using var doc = JsonDocument.Parse(content); + if (!doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksEl) || blocksEl.ValueKind != JsonValueKind.Array) + return false; + + var assistantTextParts = new List(); + var toolNames = new List(); + var toolIds = new List(); + foreach (var block in blocksEl.EnumerateArray()) + { + if (!block.TryGetProperty("type", out var typeEl)) + continue; + + var type = typeEl.GetString() ?? ""; + if (string.Equals(type, "text", StringComparison.OrdinalIgnoreCase)) + { + var text = block.TryGetProperty("text", out var textEl) + ? NormalizePreviewText(textEl.GetString() ?? "") + : ""; + if (!string.IsNullOrWhiteSpace(text)) + assistantTextParts.Add(text); + continue; + } + + if (!string.Equals(type, "tool_use", StringComparison.OrdinalIgnoreCase)) + continue; + + var toolName = block.TryGetProperty("name", out var toolNameEl) + ? toolNameEl.GetString() ?? "" + : ""; + var toolId = block.TryGetProperty("id", out var toolIdEl) + ? toolIdEl.GetString() ?? "" + : ""; + + if (!string.IsNullOrWhiteSpace(toolName)) + toolNames.Add(toolName); + if (!string.IsNullOrWhiteSpace(toolId)) + toolIds.Add(toolId); + } + + snapshot = new StructuredAssistantToolUseSnapshot( + messageIndex, + string.Join(" ", assistantTextParts), + toolNames, + toolIds); + return toolIds.Count > 0; + } + catch + { + return false; + } + } + + private static string BuildAssistantToolTranscript(string assistantText, IReadOnlyList toolNames) + { + var distinctNames = toolNames + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var toolSummary = distinctNames.Count > 0 + ? "[previous tool call] " + string.Join(", ", distinctNames) + : "[previous tool call]"; + + if (string.IsNullOrWhiteSpace(assistantText)) + return toolSummary; + + return assistantText + "\n" + toolSummary; + } + + private static string BuildToolResultTranscript(ChatMessage message) + { + var toolName = ""; + var previewText = ""; + + if (!string.IsNullOrWhiteSpace(message.QueryPreviewContent) + && TryParseToolResultPreviewPayload(message.QueryPreviewContent!, out var previewToolName, out var previewPayloadText)) + { + toolName = previewToolName; + previewText = previewPayloadText; + } + + if (string.IsNullOrWhiteSpace(previewText) + && TryParseToolResultPreviewPayload(message.Content ?? "", out var contentToolName, out var contentPayloadText)) + { + toolName = string.IsNullOrWhiteSpace(toolName) ? contentToolName : toolName; + previewText = contentPayloadText; + } + + if (string.IsNullOrWhiteSpace(previewText)) + previewText = "tool result available"; + + var header = string.IsNullOrWhiteSpace(toolName) + ? "[previous tool result]" + : $"[previous tool result: {toolName}]"; + return header + "\n" + previewText; + } + + private static bool TryParseToolResultPreviewPayload(string payload, out string toolName, out string previewText) + { + toolName = ""; + previewText = ""; + if (string.IsNullOrWhiteSpace(payload)) + return false; + + try + { + using var doc = JsonDocument.Parse(payload); + var root = doc.RootElement; + toolName = root.TryGetProperty("tool_name", out var toolNameEl) + ? toolNameEl.GetString() ?? "" + : ""; + previewText = ExtractPreviewText(root); + return !string.IsNullOrWhiteSpace(previewText); + } + catch + { + return false; + } + } + private static string ExtractPreviewText(JsonElement root) { if (root.TryGetProperty("content", out var contentEl)) diff --git a/src/AxCopilot/Services/Agent/SessionLearningCollector.cs b/src/AxCopilot/Services/Agent/SessionLearningCollector.cs index eca6764..e869c83 100644 --- a/src/AxCopilot/Services/Agent/SessionLearningCollector.cs +++ b/src/AxCopilot/Services/Agent/SessionLearningCollector.cs @@ -4,8 +4,8 @@ using System.Text.RegularExpressions; namespace AxCopilot.Services.Agent; /// -/// 에이전트 루프 실행 중 도구 결과에서 학습 포인트를 자동 추출하여 -/// 후속 반복에 컨텍스트로 주입하는 세션 내 단기 학습 수집기. +/// Extracts small reusable learnings from tool outputs and injects them back +/// into later turns as lightweight session memory. /// internal sealed class SessionLearningCollector { @@ -13,24 +13,33 @@ internal sealed class SessionLearningCollector private readonly object _lock = new(); private readonly int _maxLearnings; - /// 도구 출력이 이 크기를 초과하면 앞부분만 사용하여 메모리 보호. + /// + /// Limits how much tool output is analyzed so memory extraction does not + /// over-allocate when a tool prints very large logs. + /// private const int MaxOutputAnalysisLength = 32_000; public SessionLearningCollector(int maxLearnings = 10) => _maxLearnings = maxLearnings; - public int Count { get { lock (_lock) return _learnings.Count; } } + public int Count + { + get + { + lock (_lock) + return _learnings.Count; + } + } /// - /// 도구 실행 결과에서 학습 포인트를 추출합니다. - /// 추출 규칙에 매칭되면 자동으로 저장됩니다. + /// Attempts to infer a reusable session learning from a tool result. + /// Matching learnings are stored automatically. /// public void TryExtract(string toolName, string toolOutput, bool success) { if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(toolOutput)) return; - // 대용량 출력 보호: 앞부분만 분석 (Split('\n') 메모리 폭발 방지) var safeOutput = toolOutput.Length > MaxOutputAnalysisLength ? toolOutput[..MaxOutputAnalysisLength] : toolOutput; @@ -57,21 +66,22 @@ internal sealed class SessionLearningCollector } catch { - // 추출 실패는 무시 — 학습 수집은 부수 효과 + // Learning extraction must never break the main agent loop. } - if (learning is null) return; + if (learning is null) + return; - // 중복 방지: 동일 카테고리+내용이 이미 있으면 건너뜀 lock (_lock) { if (_learnings.Any(l => l.Category == learning.Category && l.Content.Equals(learning.Content, StringComparison.OrdinalIgnoreCase))) + { return; + } _learnings.Add(learning); - // FIFO: 초과 시 가장 오래된 항목 일괄 제거 (RemoveAt(0) 반복 대비 O(n) → O(1)) var excess = _learnings.Count - _maxLearnings; if (excess > 0) _learnings.RemoveRange(0, excess); @@ -79,122 +89,120 @@ internal sealed class SessionLearningCollector } /// - /// 현재 누적 학습을 시스템 메시지 형태로 포맷합니다. - /// 학습이 없으면 null을 반환합니다. + /// Builds the current learnings as an injection block. Returns null when + /// there is nothing to inject. /// public string? BuildInjectionMessage() { List snapshot; lock (_lock) { - if (_learnings.Count == 0) return null; + if (_learnings.Count == 0) + return null; + snapshot = _learnings.ToList(); } var sb = new StringBuilder(); - sb.AppendLine("[System:SessionLearnings] 이 세션에서 자동 수집된 학습 사항:"); - foreach (var l in snapshot) - { - sb.AppendLine($"- [{l.Category}] {l.Content}"); - } - sb.AppendLine("위 내용을 참고하여 동일 실수를 반복하지 마세요."); + sb.AppendLine("[System:SessionLearnings] Reusable learnings collected in this session:"); + foreach (var learning in snapshot) + sb.AppendLine($"- [{learning.Category}] {learning.Content}"); + sb.AppendLine("Use these notes to avoid repeating the same failed attempts."); return sb.ToString().TrimEnd(); } - /// 모든 학습 초기화. + /// Clears all collected learnings. public void Clear() { - lock (_lock) _learnings.Clear(); + lock (_lock) + _learnings.Clear(); } - // ════════════════════════════════════════════════════════════ - // 추출 규칙 - // ════════════════════════════════════════════════════════════ + // Extraction rules - /// 빌드/테스트 실패에서 프로젝트 설정 학습. + /// Extracts project/build configuration hints from failed builds or tests. private static SessionLearning? ExtractBuildConfig(string tool, string output) { var sb = new StringBuilder(); - // .NET 타겟 프레임워크 감지 var tfmMatch = Regex.Match(output, @"net\d+\.\d+(?:-windows[\d.]*)?", RegexOptions.IgnoreCase); if (tfmMatch.Success) - sb.Append($"타겟: {tfmMatch.Value}"); + sb.Append($"target={tfmMatch.Value}"); - // 에러 코드 추출 (CS, TS, etc.) var errorCodes = Regex.Matches(output, @"\b(CS|TS|E)\d{4}\b"); if (errorCodes.Count > 0) { var codes = errorCodes.Cast().Select(m => m.Value).Distinct().Take(5); - if (sb.Length > 0) sb.Append(", "); - sb.Append($"에러: {string.Join(", ", codes)}"); + if (sb.Length > 0) + sb.Append(", "); + sb.Append($"errors={string.Join(", ", codes)}"); } - // 빌드 시스템 감지 if (output.Contains("MSBuild", StringComparison.OrdinalIgnoreCase)) { - if (sb.Length > 0) sb.Append(", "); - sb.Append("빌드: MSBuild"); + if (sb.Length > 0) + sb.Append(", "); + sb.Append("build=MSBuild"); } else if (output.Contains("npm", StringComparison.OrdinalIgnoreCase) || output.Contains("node", StringComparison.OrdinalIgnoreCase)) { - if (sb.Length > 0) sb.Append(", "); - sb.Append("빌드: npm/node"); + if (sb.Length > 0) + sb.Append(", "); + sb.Append("build=npm/node"); } - // 주요 에러 메시지 첫 줄 (Split 대신 라인별 스캔으로 메모리 절약) var errorLine = FindFirstMatchingLine(output, - l => l.Contains("error", StringComparison.OrdinalIgnoreCase) - && l.Length > 10 && l.Length < 200); + line => line.Contains("error", StringComparison.OrdinalIgnoreCase) + && line.Length > 10 && line.Length < 200); if (errorLine != null) { - if (sb.Length > 0) sb.Append(" — "); + if (sb.Length > 0) + sb.Append(" :: "); sb.Append(Truncate(errorLine, 120)); } return sb.Length > 0 - ? new("build_config", sb.ToString(), DateTime.Now, tool) + ? new SessionLearning("build_config", sb.ToString(), DateTime.Now, tool) : null; } - /// grep/glob 결과에서 코드 위치 패턴 학습. + /// Extracts repeated code-location patterns from grep or glob results. private static SessionLearning? ExtractCodeLocation(string tool, string output) { - // 파일 경로 추출 var paths = Regex.Matches(output, @"(?:^|\s)([\w./\\-]+\.\w{1,6})(?:\s|:|$)", RegexOptions.Multiline) .Cast() .Select(m => m.Groups[1].Value) - .Where(p => p.Contains('/') || p.Contains('\\')) + .Where(path => path.Contains('/') || path.Contains('\\')) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(5) .ToList(); - if (paths.Count < 2) return null; // 단일 파일이면 학습 가치 낮음 + if (paths.Count < 2) + return null; - // 공통 디렉토리 패턴 추출 var dirs = paths - .Select(p => string.Join("/", p.Replace('\\', '/').Split('/').SkipLast(1))) - .Where(d => !string.IsNullOrEmpty(d)) - .GroupBy(d => d) - .OrderByDescending(g => g.Count()) + .Select(path => string.Join("/", path.Replace('\\', '/').Split('/').SkipLast(1))) + .Where(dir => !string.IsNullOrEmpty(dir)) + .GroupBy(dir => dir) + .OrderByDescending(group => group.Count()) .FirstOrDefault(); - if (dirs == null) return null; + if (dirs == null) + return null; - var content = $"관련 파일이 {dirs.Key}/ 에 집중 ({paths.Count}개 파일)"; - return new("code_location", content, DateTime.Now, tool); + var content = $"related files are clustered under {dirs.Key}/ ({paths.Count} files)"; + return new SessionLearning("code_location", content, DateTime.Now, tool); } - /// 프로젝트 메타 파일 읽기에서 구조 학습. + /// Extracts structure hints from project metadata files. private static SessionLearning? ExtractProjectStructure(string tool, string output) { var sb = new StringBuilder(); - // csproj: TargetFramework, PackageReference var tfm = Regex.Match(output, @"(.*?)", RegexOptions.IgnoreCase); if (tfm.Success) - sb.Append($"프레임워크: {tfm.Groups[1].Value}"); + sb.Append($"framework={tfm.Groups[1].Value}"); var packages = Regex.Matches(output, @"() @@ -203,62 +211,64 @@ internal sealed class SessionLearningCollector .ToList(); if (packages.Count > 0) { - if (sb.Length > 0) sb.Append(", "); - sb.Append($"주요 패키지: {string.Join(", ", packages)}"); + if (sb.Length > 0) + sb.Append(", "); + sb.Append($"packages={string.Join(", ", packages)}"); } - // package.json: name, dependencies var pkgName = Regex.Match(output, @"""name""\s*:\s*""([^""]+)"""); if (pkgName.Success) { - if (sb.Length > 0) sb.Append(", "); - sb.Append($"패키지: {pkgName.Groups[1].Value}"); + if (sb.Length > 0) + sb.Append(", "); + sb.Append($"package={pkgName.Groups[1].Value}"); } return sb.Length > 0 - ? new("project_structure", Truncate(sb.ToString(), 200), DateTime.Now, tool) + ? new SessionLearning("project_structure", Truncate(sb.ToString(), 200), DateTime.Now, tool) : null; } - /// 런타임 감지 결과에서 의존성 학습. + /// Extracts environment and dependency hints from environment detection. private static SessionLearning? ExtractDependency(string tool, string output) { - if (output.Length < 10) return null; + if (output.Length < 10) + return null; - // 주요 런타임/SDK 정보만 추출 (Split 대신 라인별 스캔) var lines = FindMatchingLines(output, 4, - l => l.Length > 5 && l.Length < 150 - && (l.Contains("SDK", StringComparison.OrdinalIgnoreCase) - || l.Contains("runtime", StringComparison.OrdinalIgnoreCase) - || l.Contains("version", StringComparison.OrdinalIgnoreCase) - || l.Contains("node", StringComparison.OrdinalIgnoreCase) - || l.Contains("python", StringComparison.OrdinalIgnoreCase))); + line => line.Length > 5 && line.Length < 150 + && (line.Contains("SDK", StringComparison.OrdinalIgnoreCase) + || line.Contains("runtime", StringComparison.OrdinalIgnoreCase) + || line.Contains("version", StringComparison.OrdinalIgnoreCase) + || line.Contains("node", StringComparison.OrdinalIgnoreCase) + || line.Contains("python", StringComparison.OrdinalIgnoreCase))); - if (lines.Count == 0) return null; + if (lines.Count == 0) + return null; - return new("dependency", string.Join("; ", lines), DateTime.Now, tool); + return new SessionLearning("dependency", string.Join("; ", lines), DateTime.Now, tool); } - /// 파일 조작 실패에서 에러 패턴 학습. + /// Extracts a stable failure pattern from file operation errors. private static SessionLearning? ExtractErrorPattern(string tool, string output) { - if (output.Length < 10) return null; + if (output.Length < 10) + return null; - var firstLine = FindFirstMatchingLine(output, l => l.Length > 5); - if (firstLine == null) return null; + var firstLine = FindFirstMatchingLine(output, line => line.Length > 5); + if (firstLine == null) + return null; - return new("error_pattern", $"{tool}: {Truncate(firstLine, 150)}", DateTime.Now, tool); + return new SessionLearning("error_pattern", $"{tool}: {Truncate(firstLine, 150)}", DateTime.Now, tool); } - // ════════════════════════════════════════════════════════════ - // 유틸 - // ════════════════════════════════════════════════════════════ + // Helpers private static bool IsProjectMetaFile(string output) - => output.Contains(" output.Contains(" tool is "file_write" or "file_edit" or "file_read" or "file_manage"; @@ -267,8 +277,8 @@ internal sealed class SessionLearningCollector => text.Length <= maxLen ? text : text[..maxLen] + "..."; /// - /// Split('\n') 없이 라인별 스캔하여 첫 매칭 라인을 반환합니다. - /// 대용량 출력에서 전체 배열 할당을 방지합니다. + /// Scans line by line without allocating a full Split('\n') array and + /// returns the first matching line. /// private static string? FindFirstMatchingLine(string text, Func predicate) { @@ -280,14 +290,17 @@ internal sealed class SessionLearningCollector var line = lineSpan.Trim().ToString(); if (predicate(line)) return line; - if (newlineIdx < 0) break; + if (newlineIdx < 0) + break; span = span[(newlineIdx + 1)..]; } + return null; } /// - /// Split('\n') 없이 라인별 스캔하여 최대 maxCount개의 매칭 라인을 반환합니다. + /// Scans line by line without allocating a full Split('\n') array and + /// returns up to matching lines. /// private static List FindMatchingLines(string text, int maxCount, Func predicate) { @@ -300,9 +313,11 @@ internal sealed class SessionLearningCollector var line = lineSpan.Trim().ToString(); if (predicate(line)) results.Add(line); - if (newlineIdx < 0) break; + if (newlineIdx < 0) + break; span = span[(newlineIdx + 1)..]; } + return results; } }