코드탭 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

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