AX Agent 진행 이력 파편 메시지 정제 및 렌더링 안정화

- 스트리밍 TextDelta와 Thinking summary에 공통 정제기를 적용해 1, [, file_read] 같은 저품질 파편 문구가 이벤트와 카드에 쌓이지 않도록 개선

- V2 라이브 진행 카드와 이력 렌더링에서 정제된 thinking summary만 표시하고 low-signal 조각은 숨기며 process feed는 안전한 기본 문구로 폴백

- AgentProgressSummarySanitizerTests와 AgentLoopResponseClassificationServiceTests를 추가/확장하고 dotnet build 경고 0 오류 0, 지정 테스트 22건 통과를 확인
This commit is contained in:
2026-04-15 12:51:53 +09:00
parent 5e40204e80
commit f3a31e97b1
10 changed files with 208 additions and 17 deletions

View File

@@ -39,4 +39,27 @@ public class AgentLoopResponseClassificationServiceTests
result.TextParts.Should().HaveCount(2);
result.NextConsecutiveNoToolResponses.Should().Be(2);
}
[Fact]
public void BuildThinkingSummary_ShouldDropTranscriptArtifacts()
{
var blocks = new List<ContentBlock>
{
new()
{
Type = "text",
Text = """
[이전 도구 호출: file_read]
1.
file_read]
.
"""
},
new() { Type = "tool_use", ToolName = "file_read", ToolId = "tool-1" }
};
var result = AgentLoopResponseClassificationService.Classify(blocks, consecutiveNoToolResponses: 0);
result.BuildThinkingSummary().Should().Be("실제 수정 범위를 다시 확인합니다.");
}
}

View File

@@ -0,0 +1,47 @@
using AxCopilot.Services.Agent;
using FluentAssertions;
using Xunit;
namespace AxCopilot.Tests.Services;
public class AgentProgressSummarySanitizerTests
{
[Theory]
[InlineData("1")]
[InlineData("1.")]
[InlineData("[")]
[InlineData("[O/")]
[InlineData("file_read]")]
public void NormalizeThinkingSummary_ShouldDropLowSignalFragments(string fragment)
{
AgentProgressSummarySanitizer.NormalizeThinkingSummary(fragment).Should().BeEmpty();
}
[Fact]
public void NormalizeThinkingSummary_ShouldStripPreviousToolCallTranscriptHints()
{
var summary = """
[이전 도구 호출: file_read]
1. time-clock.html .
""";
var normalized = AgentProgressSummarySanitizer.NormalizeThinkingSummary(summary);
normalized.Should().Be("time-clock.html 파일을 새로 생성했습니다.");
}
[Fact]
public void NormalizeThinkingSummary_ShouldTrimAndClampMeaningfulText()
{
var summary = """
1.
2. HTML .
""";
var normalized = AgentProgressSummarySanitizer.NormalizeThinkingSummary(summary, maxLength: 20);
normalized.Should().StartWith("현재 디렉터리 확인 중 필요한");
normalized.Should().EndWith("…");
}
}

View File

@@ -10,17 +10,12 @@ internal sealed record AgentLoopResponseClassificationResult(
{
public string BuildThinkingSummary(int maxLength = 150)
{
if (string.IsNullOrEmpty(TextResponse))
return string.Empty;
return TextResponse.Length > maxLength
? TextResponse[..maxLength] + "…"
: TextResponse;
return AgentProgressSummarySanitizer.NormalizeThinkingSummary(TextResponse, maxLength: maxLength);
}
}
/// <summary>
/// LLM 응답 블록을 텍스트와 tool_use로 분리하고, 무도구 응답 연속 횟수를 계산한다.
/// Splits LLM response blocks into text and tool_use items and tracks consecutive no-tool replies.
/// </summary>
internal static class AgentLoopResponseClassificationService
{

View File

@@ -582,10 +582,14 @@ public partial class AgentLoopService
var now = DateTime.UtcNow;
if ((now - lastStreamUiUpdateAt).TotalMilliseconds >= 450 && streamedTextPreview.Length > 0)
{
var preview = streamedTextPreview.ToString();
preview = preview.Length > 140 ? preview[..140] + "…" : preview;
EmitEvent(AgentEventType.Thinking, "", preview);
lastStreamUiUpdateAt = now;
var preview = AgentProgressSummarySanitizer.NormalizeThinkingSummary(
streamedTextPreview.ToString(),
maxLength: 140);
if (!string.IsNullOrWhiteSpace(preview))
{
EmitEvent(AgentEventType.Thinking, "", preview);
lastStreamUiUpdateAt = now;
}
}
}
break;
@@ -4952,6 +4956,13 @@ public partial class AgentLoopService
long elapsedMs = 0, int inputTokens = 0, int outputTokens = 0,
string? toolInput = null, int iteration = 0, bool? successOverride = null)
{
if (type == AgentEventType.Thinking)
{
summary = AgentProgressSummarySanitizer.NormalizeThinkingSummary(summary, toolName);
if (string.IsNullOrWhiteSpace(summary))
return;
}
if (type == AgentEventType.Thinking && ShouldSuppressPostCompactionThinking(summary))
{
_runPostCompactionSuppressedThinkingCount++;

View File

@@ -0,0 +1,90 @@
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
internal static class AgentProgressSummarySanitizer
{
private static readonly Regex s_previousToolCallRegex =
new(@"\[이전 도구 호출[^\]\n]*(?:\]|$)", RegexOptions.Compiled);
private static readonly Regex s_previousToolBundleRegex =
new(@"\[이전 도구 호출 묶음 축약[^\]\n]*(?:\]|$)", RegexOptions.Compiled);
private static readonly Regex s_listPrefixRegex =
new(@"^\s*(?:\d+\s*[\.\)]|[-*•])\s*", RegexOptions.Compiled);
private static readonly Regex s_punctuationOnlyRegex =
new(@"^[\[\]\(\)\{\}/\\<>\.\-_:;,`'""*]+$", RegexOptions.Compiled);
private static readonly Regex s_numericFragmentRegex =
new(@"^\d+\s*[\.\)]?$", RegexOptions.Compiled);
private static readonly Regex s_toolTokenRegex =
new(@"^[A-Za-z][A-Za-z0-9_\-]{1,40}\]?$", RegexOptions.Compiled);
public static string NormalizeThinkingSummary(string? summary, string? toolName = null, int maxLength = 0)
{
if (string.IsNullOrWhiteSpace(summary))
return string.Empty;
var text = summary
.Replace("\r\n", "\n")
.Replace('\r', '\n')
.Replace("```", string.Empty)
.Replace("**", string.Empty)
.Replace('`', ' ');
text = s_previousToolCallRegex.Replace(text, string.Empty);
text = s_previousToolBundleRegex.Replace(text, string.Empty);
var cleanedLines = text
.Split('\n')
.Select(CleanThinkingLine)
.Where(line => !string.IsNullOrWhiteSpace(line))
.Where(line => !IsLowSignalFragment(line))
.Distinct(StringComparer.Ordinal)
.ToList();
if (cleanedLines.Count == 0)
return string.Empty;
var normalized = string.Join(" ", cleanedLines);
normalized = Regex.Replace(normalized, @"\s{2,}", " ").Trim();
if (maxLength > 0 && normalized.Length > maxLength)
normalized = normalized[..maxLength].TrimEnd() + "…";
return normalized;
}
private static string CleanThinkingLine(string line)
{
var cleaned = s_listPrefixRegex.Replace(line.Trim(), string.Empty).Trim();
cleaned = cleaned.Trim('*', '-', '•', '[', ']', '(', ')', '{', '}', ':', ';');
cleaned = Regex.Replace(cleaned, @"\s{2,}", " ").Trim();
return cleaned;
}
private static bool IsLowSignalFragment(string line)
{
if (string.IsNullOrWhiteSpace(line))
return true;
if (s_numericFragmentRegex.IsMatch(line))
return true;
if (s_punctuationOnlyRegex.IsMatch(line))
return true;
if (s_toolTokenRegex.IsMatch(line))
return true;
if (line.Length <= 3 && line.Any(ch => char.IsPunctuation(ch)))
return true;
if (line.Length <= 4 && line.All(ch => char.IsPunctuation(ch) || char.IsWhiteSpace(ch)))
return true;
return false;
}
}

View File

@@ -187,7 +187,11 @@ public partial class ChatWindow
AgentEventType.StepDone when evt.StepTotal > 0
=> $"{evt.StepCurrent}/{evt.StepTotal} 단계 완료",
AgentEventType.Thinking when !string.IsNullOrWhiteSpace(evt.Summary)
=> evt.Summary,
=> AgentProgressSummarySanitizer.NormalizeThinkingSummary(evt.Summary, evt.ToolName, maxLength: 120) switch
{
{ Length: > 0 } cleaned => cleaned,
_ => "진행 내용 정리",
},
AgentEventType.ToolCall
=> string.IsNullOrWhiteSpace(itemDisplayName)
? $"{transcriptBadgeLabel} 실행"

View File

@@ -302,7 +302,10 @@ public partial class ChatWindow
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var msgMaxWidth = GetMessageMaxWidth();
var summary = agentEvent.Summary;
var summary = AgentProgressSummarySanitizer.NormalizeThinkingSummary(
agentEvent.Summary,
agentEvent.ToolName,
maxLength: 200);
if (string.IsNullOrWhiteSpace(summary))
return new Border { Width = 0, Height = 0 }; // 빈 thinking은 숨김
@@ -356,7 +359,7 @@ public partial class ChatWindow
});
thinkStack.Children.Add(new TextBlock
{
Text = summary.Length > 200 ? summary[..200] + "..." : summary,
Text = summary,
FontSize = 11,
FontStyle = FontStyles.Italic,
Foreground = secondaryText,

View File

@@ -301,7 +301,11 @@ public partial class ChatWindow
case AgentEventType.Thinking:
{
if (string.IsNullOrWhiteSpace(agentEvent.Summary)) break;
var thinkText = AgentProgressSummarySanitizer.NormalizeThinkingSummary(
agentEvent.Summary,
agentEvent.ToolName,
maxLength: 100);
if (string.IsNullOrWhiteSpace(thinkText)) break;
// 사고 과정을 간략히 표시
var thinkRow = new StackPanel
@@ -318,8 +322,6 @@ public partial class ChatWindow
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 4, 0),
});
var thinkText = agentEvent.Summary;
if (thinkText.Length > 100) thinkText = thinkText[..100] + "...";
thinkRow.Children.Add(new TextBlock
{
Text = thinkText,