코드탭 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:
25
README.md
25
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user