코드탭 tool trace 사전 정규화와 인코딩 정리 적용

- 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)로 검증함
This commit is contained in:
2026-04-16 01:59:16 +09:00
parent 0f64bf3f84
commit f0f1f76f48
10 changed files with 518 additions and 101 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<ChatMessage>
{
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\"");
}
}

View File

@@ -7,6 +7,66 @@ namespace AxCopilot.Tests.Services;
public class AgentMessageInvariantHelperTests
{
[Fact]
public void NormalizeHistoricalToolTrace_ShouldFlattenAssistantWithMissingToolResult()
{
var messages = new List<ChatMessage>
{
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<ChatMessage>
{
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<ChatMessage>
{
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()
{

View File

@@ -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}";
}
}

View File

@@ -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)

View File

@@ -6,7 +6,9 @@ internal sealed record AgentLoopLlmRequestPreparationResult(
List<ChatMessage> SendMessages,
bool ForceInitialToolCall,
bool InjectedToolReminder,
int SupplementalMessageCount);
int SupplementalMessageCount,
int FlattenedStructuredAssistantCount,
int ConvertedOrphanToolResultCount);
/// <summary>
/// Builds the final LLM request array from the query window and optional
@@ -22,7 +24,8 @@ internal static class AgentLoopLlmRequestPreparationService
int noToolCallLoopRetry,
IEnumerable<ChatMessage>? 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(),
};
}
}

View File

@@ -4,7 +4,7 @@ using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
/// <summary>
/// tool_use / tool_result 불변식을 유지하기 위한 공용 helper입니다.
/// Shared helpers for preserving valid tool-use / tool-result history.
/// </summary>
internal static class AgentMessageInvariantHelper
{
@@ -16,6 +16,19 @@ internal static class AgentMessageInvariantHelper
IReadOnlyDictionary<string, string> ExplicitByFingerprint,
IReadOnlyDictionary<string, string> SyntheticByToolResultId);
internal sealed record StructuredAssistantToolUseSnapshot(
int MessageIndex,
string AssistantText,
IReadOnlyList<string> ToolNames,
IReadOnlyList<string> ToolIds);
public sealed record HistoricalToolTraceNormalizationResult(
int FlattenedAssistantCount,
int ConvertedOrphanToolResultCount)
{
public int TotalAdjustedCount => FlattenedAssistantCount + ConvertedOrphanToolResultCount;
}
public static Dictionary<string, string> BuildToolResultPreviewMap(IEnumerable<ChatMessage>? 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<ChatMessage>? messages)
{
if (messages == null || messages.Count == 0)
return new HistoricalToolTraceNormalizationResult(0, 0);
var structuredAssistants = new List<StructuredAssistantToolUseSnapshot>();
var assistantIndexByToolUseId = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var toolResultIds = new HashSet<string>(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<string>(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<string>();
var toolNames = new List<string>();
var toolIds = new List<string>();
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<string> 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))

View File

@@ -4,8 +4,8 @@ using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 에이전트 루프 실행 중 도구 결과에서 학습 포인트를 자동 추출하여
/// 후속 반복에 컨텍스트로 주입하는 세션 내 단기 학습 수집기.
/// Extracts small reusable learnings from tool outputs and injects them back
/// into later turns as lightweight session memory.
/// </summary>
internal sealed class SessionLearningCollector
{
@@ -13,24 +13,33 @@ internal sealed class SessionLearningCollector
private readonly object _lock = new();
private readonly int _maxLearnings;
/// <summary>도구 출력이 이 크기를 초과하면 앞부분만 사용하여 메모리 보호.</summary>
/// <summary>
/// Limits how much tool output is analyzed so memory extraction does not
/// over-allocate when a tool prints very large logs.
/// </summary>
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;
}
}
/// <summary>
/// 도구 실행 결과에서 학습 포인트를 추출합니다.
/// 추출 규칙에 매칭되면 자동으로 저장됩니다.
/// Attempts to infer a reusable session learning from a tool result.
/// Matching learnings are stored automatically.
/// </summary>
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
}
/// <summary>
/// 현재 누적 학습을 시스템 메시지 형태로 포맷합니다.
/// 학습이 없으면 null을 반환합니다.
/// Builds the current learnings as an injection block. Returns null when
/// there is nothing to inject.
/// </summary>
public string? BuildInjectionMessage()
{
List<SessionLearning> 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();
}
/// <summary>모든 학습 초기화.</summary>
/// <summary>Clears all collected learnings.</summary>
public void Clear()
{
lock (_lock) _learnings.Clear();
lock (_lock)
_learnings.Clear();
}
// ════════════════════════════════════════════════════════════
// 추출 규칙
// ════════════════════════════════════════════════════════════
// Extraction rules
/// <summary>빌드/테스트 실패에서 프로젝트 설정 학습.</summary>
/// <summary>Extracts project/build configuration hints from failed builds or tests.</summary>
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<Match>().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;
}
/// <summary>grep/glob 결과에서 코드 위치 패턴 학습.</summary>
/// <summary>Extracts repeated code-location patterns from grep or glob results.</summary>
private static SessionLearning? ExtractCodeLocation(string tool, string output)
{
// 파일 경로 추출
var paths = Regex.Matches(output, @"(?:^|\s)([\w./\\-]+\.\w{1,6})(?:\s|:|$)", RegexOptions.Multiline)
.Cast<Match>()
.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);
}
/// <summary>프로젝트 메타 파일 읽기에서 구조 학습.</summary>
/// <summary>Extracts structure hints from project metadata files.</summary>
private static SessionLearning? ExtractProjectStructure(string tool, string output)
{
var sb = new StringBuilder();
// csproj: TargetFramework, PackageReference
var tfm = Regex.Match(output, @"<TargetFramework[s]?>(.*?)</TargetFramework[s]?>", RegexOptions.IgnoreCase);
if (tfm.Success)
sb.Append($"프레임워크: {tfm.Groups[1].Value}");
sb.Append($"framework={tfm.Groups[1].Value}");
var packages = Regex.Matches(output, @"<PackageReference\s+Include=""([^""]+)""", RegexOptions.IgnoreCase)
.Cast<Match>()
@@ -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;
}
/// <summary>런타임 감지 결과에서 의존성 학습.</summary>
/// <summary>Extracts environment and dependency hints from environment detection.</summary>
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);
}
/// <summary>파일 조작 실패에서 에러 패턴 학습.</summary>
/// <summary>Extracts a stable failure pattern from file operation errors.</summary>
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("<Project", StringComparison.OrdinalIgnoreCase) // csproj
|| output.Contains("\"dependencies\"", StringComparison.OrdinalIgnoreCase) // package.json
|| output.Contains("[package]", StringComparison.OrdinalIgnoreCase) // Cargo.toml
|| output.Contains("\"name\":", StringComparison.OrdinalIgnoreCase); // package.json
=> output.Contains("<Project", StringComparison.OrdinalIgnoreCase)
|| output.Contains("\"dependencies\"", StringComparison.OrdinalIgnoreCase)
|| output.Contains("[package]", StringComparison.OrdinalIgnoreCase)
|| output.Contains("\"name\":", StringComparison.OrdinalIgnoreCase);
private static bool IsFileOperationTool(string tool)
=> 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] + "...";
/// <summary>
/// Split('\n') 없이 라인별 스캔하여 첫 매칭 라인을 반환합니다.
/// 대용량 출력에서 전체 배열 할당을 방지합니다.
/// Scans line by line without allocating a full Split('\n') array and
/// returns the first matching line.
/// </summary>
private static string? FindFirstMatchingLine(string text, Func<string, bool> 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;
}
/// <summary>
/// Split('\n') 없이 라인별 스캔하여 최대 maxCount개의 매칭 라인을 반환합니다.
/// Scans line by line without allocating a full Split('\n') array and
/// returns up to <paramref name="maxCount"/> matching lines.
/// </summary>
private static List<string> FindMatchingLines(string text, int maxCount, Func<string, bool> 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;
}
}