목적: - 긴 세션, compact, query view 생성 시점에도 tool_result preview 축약 상태를 더 안정적으로 유지합니다. - 격리 환경에서 로컬 LSP 서버가 없더라도 코드 탭이 언어별 정적 분석 힌트를 계속 제공하도록 마감합니다. - 설정/프롬프트/로드맵 문서까지 현재 구현 상태와 일치시키고 남은 고도화 범위를 정리합니다. 핵심 수정: - AgentQueryContextBuilder와 ContextCondenser가 query/compact 진입 전에 누락된 tool_result preview를 먼저 복원하도록 정리했습니다. - AgentToolResultBudget는 sourceMessages가 없는 호출에서도 현재 window의 tool_use_id preview를 재사용하도록 보강했습니다. - CodeLanguageCatalog에 언어별 manifest/build/test/lint fallback 힌트를 추가하고, LspTool은 LSP 서버 미가동 시 정적 fallback 안내를 반환하도록 변경했습니다. - SettingsViewModel, SettingsWindow, ChatWindow.SystemPromptBuilder에 Fallback 분석 설명과 LSP 미사용 시 대체 분석 지침을 반영했습니다. - AgentQueryContextBuilderTests를 새로 추가하고 AgentToolResultBudgetTests, CodeLanguageCatalogTests를 확장했습니다. - README.md, docs/DEVELOPMENT.md, docs/AGENT_ROADMAP.md, docs/NEXT_ROADMAP.md를 2026-04-15 08:32 (KST) 기준으로 갱신했습니다. 검증: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_loop_lang_finish\\ -p:IntermediateOutputPath=obj\\verify_loop_lang_finish\\ - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolResultBudgetTests|AgentQueryContextBuilderTests|CodeLanguageCatalogTests|ContextCondenserTests" -p:OutputPath=bin\\verify_loop_lang_finish_tests\\ -p:IntermediateOutputPath=obj\\verify_loop_lang_finish_tests\\ (통과 20)
243 lines
9.8 KiB
C#
243 lines
9.8 KiB
C#
using AxCopilot.Models;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
public sealed class AgentQueryContextWindowResult
|
|
{
|
|
public required List<ChatMessage> Messages { get; init; }
|
|
public int SourceMessageCount { get; init; }
|
|
public int ViewMessageCount { get; init; }
|
|
public int WindowStartIndex { get; init; }
|
|
public bool BoundaryApplied { get; init; }
|
|
public bool ToolPairExpanded { get; init; }
|
|
public int PreservedToolPairCount { get; init; }
|
|
public int TruncatedToolResultCount { get; init; }
|
|
public int ReusedToolResultPreviewCount { get; init; }
|
|
public int TokensBeforeBudget { get; init; }
|
|
public int TokensAfterBudget { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// claude-code의 messagesForQuery처럼, 저장된 전체 대화와 실제 LLM에 보내는 query view를 분리합니다.
|
|
/// </summary>
|
|
public static class AgentQueryContextBuilder
|
|
{
|
|
private const int ProtectedRecentNonSystemMessages = 8;
|
|
private const string PostCompactContextMetaKind = "post_compact_context";
|
|
|
|
public static AgentQueryContextWindowResult Build(IReadOnlyList<ChatMessage> sourceMessages)
|
|
{
|
|
if (sourceMessages is IList<ChatMessage> mutableSourceMessages)
|
|
AgentMessageInvariantHelper.PopulateMissingToolResultPreviews(mutableSourceMessages);
|
|
|
|
if (sourceMessages.Count == 0)
|
|
{
|
|
return new AgentQueryContextWindowResult
|
|
{
|
|
Messages = new List<ChatMessage>(),
|
|
SourceMessageCount = 0,
|
|
ViewMessageCount = 0,
|
|
WindowStartIndex = 0,
|
|
BoundaryApplied = false,
|
|
ToolPairExpanded = false,
|
|
PreservedToolPairCount = 0,
|
|
TruncatedToolResultCount = 0,
|
|
ReusedToolResultPreviewCount = 0,
|
|
TokensBeforeBudget = 0,
|
|
TokensAfterBudget = 0,
|
|
};
|
|
}
|
|
|
|
var startIndex = FindWindowStartIndex(sourceMessages, out var boundaryApplied);
|
|
var adjustedStartIndex = AgentMessageInvariantHelper.AdjustStartIndexForToolPairs(
|
|
sourceMessages,
|
|
startIndex,
|
|
out var preservedToolPairs);
|
|
var toolPairExpanded = adjustedStartIndex < startIndex;
|
|
|
|
var windowMessages = new List<ChatMessage>(sourceMessages.Count);
|
|
for (var i = 0; i < sourceMessages.Count; i++)
|
|
{
|
|
var include = string.Equals(sourceMessages[i].Role, "system", StringComparison.OrdinalIgnoreCase)
|
|
|| i >= adjustedStartIndex;
|
|
if (!include)
|
|
continue;
|
|
|
|
windowMessages.Add(CloneMessage(sourceMessages[i]));
|
|
}
|
|
|
|
if (boundaryApplied)
|
|
InjectPostCompactContextMessage(windowMessages);
|
|
|
|
var tokensBeforeBudget = TokenEstimator.EstimateMessages(windowMessages);
|
|
var budgetResult = AgentToolResultBudget.Apply(windowMessages, ProtectedRecentNonSystemMessages, sourceMessages: sourceMessages);
|
|
var tokensAfterBudget = TokenEstimator.EstimateMessages(windowMessages);
|
|
|
|
return new AgentQueryContextWindowResult
|
|
{
|
|
Messages = windowMessages,
|
|
SourceMessageCount = sourceMessages.Count,
|
|
ViewMessageCount = windowMessages.Count,
|
|
WindowStartIndex = adjustedStartIndex,
|
|
BoundaryApplied = boundaryApplied,
|
|
ToolPairExpanded = toolPairExpanded,
|
|
PreservedToolPairCount = preservedToolPairs,
|
|
TruncatedToolResultCount = budgetResult.TruncatedCount,
|
|
ReusedToolResultPreviewCount = budgetResult.ReusedPreviewCount,
|
|
TokensBeforeBudget = tokensBeforeBudget,
|
|
TokensAfterBudget = tokensAfterBudget,
|
|
};
|
|
}
|
|
|
|
private static int FindWindowStartIndex(IReadOnlyList<ChatMessage> sourceMessages, out bool boundaryApplied)
|
|
{
|
|
for (var i = sourceMessages.Count - 1; i >= 0; i--)
|
|
{
|
|
if (IsQueryBoundaryMarker(sourceMessages[i]))
|
|
{
|
|
boundaryApplied = true;
|
|
return i;
|
|
}
|
|
}
|
|
|
|
boundaryApplied = false;
|
|
return 0;
|
|
}
|
|
|
|
private static bool IsQueryBoundaryMarker(ChatMessage message)
|
|
{
|
|
var metaKind = message.MetaKind ?? "";
|
|
if (metaKind.Equals("microcompact_boundary", StringComparison.OrdinalIgnoreCase)
|
|
|| metaKind.Equals("session_memory_compaction", StringComparison.OrdinalIgnoreCase)
|
|
|| metaKind.Equals("collapsed_boundary", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var content = message.Content ?? "";
|
|
return content.StartsWith("[이전 대화 요약", StringComparison.Ordinal)
|
|
|| content.StartsWith("[세션 메모리 압축", StringComparison.Ordinal)
|
|
|| content.StartsWith("[이전 실행 묶음 압축", StringComparison.Ordinal)
|
|
|| content.StartsWith("[이전 압축 경계 병합", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static ChatMessage CloneMessage(ChatMessage source, string? contentOverride = null)
|
|
{
|
|
return new ChatMessage
|
|
{
|
|
MsgId = source.MsgId,
|
|
Role = source.Role,
|
|
Content = contentOverride ?? 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(),
|
|
};
|
|
}
|
|
|
|
private static void InjectPostCompactContextMessage(List<ChatMessage> messages)
|
|
{
|
|
if (messages.Count == 0)
|
|
return;
|
|
|
|
if (messages.Any(m => string.Equals(m.MetaKind, PostCompactContextMetaKind, StringComparison.OrdinalIgnoreCase)))
|
|
return;
|
|
|
|
var attachedFiles = messages
|
|
.SelectMany(m => m.AttachedFiles ?? Enumerable.Empty<string>())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Take(5)
|
|
.ToList();
|
|
var imageCount = messages.Sum(m => m.Images?.Count ?? 0);
|
|
var compactSummaryCount = messages.Count(m =>
|
|
string.Equals(m.MetaKind, "microcompact_boundary", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(m.MetaKind, "session_memory_compaction", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(m.MetaKind, "collapsed_boundary", StringComparison.OrdinalIgnoreCase));
|
|
var branchContextCount = messages.Count(m =>
|
|
string.Equals(m.MetaKind, "branch_context", StringComparison.OrdinalIgnoreCase));
|
|
var structuredToolHistoryCount = messages.Count(m =>
|
|
{
|
|
var content = m.Content ?? "";
|
|
return content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal)
|
|
|| content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal);
|
|
});
|
|
var recentToolNames = messages
|
|
.Where(m => m.Role == "user")
|
|
.Select(m => TryExtractToolResultToolName(m, out var toolName) ? toolName : "")
|
|
.Where(toolName => !string.IsNullOrWhiteSpace(toolName))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Take(4)
|
|
.ToList();
|
|
|
|
if (attachedFiles.Count == 0
|
|
&& imageCount == 0
|
|
&& compactSummaryCount == 0
|
|
&& structuredToolHistoryCount == 0
|
|
&& branchContextCount == 0
|
|
&& recentToolNames.Count == 0)
|
|
return;
|
|
|
|
var lines = new List<string> { "[post-compact context]" };
|
|
if (compactSummaryCount > 0)
|
|
lines.Add($"restored compact summaries: {compactSummaryCount}");
|
|
if (branchContextCount > 0)
|
|
lines.Add($"restored branch context: {branchContextCount}");
|
|
if (structuredToolHistoryCount > 0)
|
|
lines.Add($"restored tool history blocks: {structuredToolHistoryCount}");
|
|
if (recentToolNames.Count > 0)
|
|
lines.Add("restored recent tools: " + string.Join(", ", recentToolNames));
|
|
if (attachedFiles.Count > 0)
|
|
lines.Add("restored file refs: " + string.Join(", ", attachedFiles));
|
|
if (imageCount > 0)
|
|
lines.Add($"restored image refs: {imageCount}");
|
|
|
|
var insertIndex = 0;
|
|
while (insertIndex < messages.Count && string.Equals(messages[insertIndex].Role, "system", StringComparison.OrdinalIgnoreCase))
|
|
insertIndex++;
|
|
|
|
messages.Insert(insertIndex, new ChatMessage
|
|
{
|
|
Role = "system",
|
|
MetaKind = PostCompactContextMetaKind,
|
|
Content = string.Join("\n", lines),
|
|
Timestamp = DateTime.Now,
|
|
AttachedFiles = attachedFiles.Count > 0 ? attachedFiles : null,
|
|
});
|
|
}
|
|
|
|
private static bool TryExtractToolResultToolName(ChatMessage message, out string toolName)
|
|
{
|
|
toolName = "";
|
|
if (!string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase)
|
|
|| string.IsNullOrWhiteSpace(message.Content)
|
|
|| !message.Content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal))
|
|
return false;
|
|
|
|
try
|
|
{
|
|
using var doc = System.Text.Json.JsonDocument.Parse(message.Content);
|
|
if (doc.RootElement.TryGetProperty("tool_name", out var toolNameEl))
|
|
{
|
|
toolName = toolNameEl.GetString() ?? "";
|
|
return !string.IsNullOrWhiteSpace(toolName);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|