AX Agent 구조를 claw-code 기준으로 추가 정리해 transcript 렌더와 tool streaming 책임을 분리함
Some checks failed
Release Gate / gate (push) Has been cancelled
Some checks failed
Release Gate / gate (push) Has been cancelled
- ChatWindow.TranscriptRendering partial을 추가해 transcript windowing, 증분 렌더, 스크롤 보존 로직을 메인 ChatWindow.xaml.cs에서 분리 - StreamingToolExecutionCoordinator를 도입해 read-only 도구 prefetch, tool-use 스트리밍 수신, context overflow/transient error 복구를 별도 계층으로 이동 - AgentLoopRuntimeThresholds helper를 추가해 no-tool, plan retry, terminal evidence gate 임계값 계산을 AgentLoopService에서 분리 - AgentLoopTransitions.Execution은 coordinator thin wrapper 중심 구조로 정리해 이후 executor 고도화와 정책 변경이 덜 위험하도록 개선 - README와 docs/DEVELOPMENT.md를 2026-04-09 09:14 (KST) 기준으로 갱신 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
This commit is contained in:
@@ -1521,3 +1521,9 @@ MIT License
|
|||||||
- [ChatWindow.TranscriptHost.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptHost.cs)를 추가해 transcript 요소 추가/교체/삭제와 스크롤 접근을 공용 helper로 정리했고, 내부 ScrollViewer도 한 번만 찾아 재사용하도록 바꿨습니다.
|
- [ChatWindow.TranscriptHost.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptHost.cs)를 추가해 transcript 요소 추가/교체/삭제와 스크롤 접근을 공용 helper로 정리했고, 내부 ScrollViewer도 한 번만 찾아 재사용하도록 바꿨습니다.
|
||||||
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 메시지 영역은 `VirtualizingStackPanel`을 쓰는 `ListBox` 기반 호스트로 교체해, 이후 `claw-code`의 `VirtualMessageList`에 더 가까운 가상화 구조로 밀어갈 수 있는 기반을 만들었습니다.
|
- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 메시지 영역은 `VirtualizingStackPanel`을 쓰는 `ListBox` 기반 호스트로 교체해, 이후 `claw-code`의 `VirtualMessageList`에 더 가까운 가상화 구조로 밀어갈 수 있는 기반을 만들었습니다.
|
||||||
- 관련 렌더 코드([ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs), [ChatWindow.MessageBubblePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs), [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs), [ChatWindow.MessageInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs), [ChatWindow.TimelinePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs))도 모두 컬렉션 기반 조작으로 맞춰, 실행 중 `Children` 직접 조작에 따른 레이아웃 churn을 줄였습니다.
|
- 관련 렌더 코드([ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs), [ChatWindow.MessageBubblePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs), [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs), [ChatWindow.MessageInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs), [ChatWindow.TimelinePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs))도 모두 컬렉션 기반 조작으로 맞춰, 실행 중 `Children` 직접 조작에 따른 레이아웃 churn을 줄였습니다.
|
||||||
|
- 업데이트: 2026-04-09 09:14 (KST)
|
||||||
|
- `claw-code`와의 구조 대조 결과를 바탕으로, transcript 렌더와 tool streaming 실행 책임을 더 분리했습니다.
|
||||||
|
- [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)를 추가해 `RenderMessages()`와 transcript windowing/증분 렌더 흐름을 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 밖으로 옮겼습니다.
|
||||||
|
- [StreamingToolExecutionCoordinator.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs)를 추가해 read-only 도구 prefetch, tool-use 스트리밍 수신, context overflow/transient error 복구를 별도 coordinator 계층으로 분리했습니다.
|
||||||
|
- [AgentLoopRuntimeThresholds.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopRuntimeThresholds.cs)를 추가해 no-tool, plan retry, terminal evidence gate 같은 임계값 계산을 `AgentLoopService`에서 분리했습니다.
|
||||||
|
- 결과적으로 Cowork/Code의 핵심 루프는 정책 소비자에 더 가까워졌고, 이후 transcript 진짜 가상화와 모델별 실행 정책 조정도 덜 위험하게 진행할 수 있는 구조가 됐습니다.
|
||||||
|
|||||||
@@ -5503,3 +5503,20 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
|
|||||||
- 실행 중 `Children.Add/Remove`에 직접 매달리던 경로를 줄여 레이아웃 churn을 완화했다.
|
- 실행 중 `Children.Add/Remove`에 직접 매달리던 경로를 줄여 레이아웃 churn을 완화했다.
|
||||||
- `claw-code`의 `VirtualMessageList`처럼 완전한 전면 가상화는 아니지만, 그 단계로 가기 위한 transcript host 분리를 마쳤다.
|
- `claw-code`의 `VirtualMessageList`처럼 완전한 전면 가상화는 아니지만, 그 단계로 가기 위한 transcript host 분리를 마쳤다.
|
||||||
- 이후 단계에서는 이 컬렉션 호스트 위에 실제 item virtualization/placeholder/windowing을 더 강하게 적용할 수 있다.
|
- 이후 단계에서는 이 컬렉션 호스트 위에 실제 item virtualization/placeholder/windowing을 더 강하게 적용할 수 있다.
|
||||||
|
|
||||||
|
## 2026-04-09 09:14 (KST)
|
||||||
|
|
||||||
|
- `claw-code` 구조 대조 재점검
|
||||||
|
- [Messages.tsx](/E:/AX%20Copilot%20-%20Codex/claw-code/claw-code-f5a40b86dede580f6543bf8926c9af017eea9409/src/components/Messages.tsx), [VirtualMessageList.tsx](/E:/AX%20Copilot%20-%20Codex/claw-code/claw-code-f5a40b86dede580f6543bf8926c9af017eea9409/src/components/VirtualMessageList.tsx), [StreamingToolExecutor.ts](/E:/AX%20Copilot%20-%20Codex/claw-code/claw-code-f5a40b86dede580f6543bf8926c9af017eea9409/src/services/tools/StreamingToolExecutor.ts)를 다시 대조한 결과, AX는 메인 창과 AgentLoop에 아직 일부 오케스트레이션 책임이 과밀한 상태였다.
|
||||||
|
- [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)
|
||||||
|
- transcript windowing, 증분 렌더, scroll preserve를 전담하는 partial을 새로 추가했다.
|
||||||
|
- 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서 `RenderMessages()`와 관련 스크롤 처리 로직을 분리해, transcript 구조 개선을 독립적으로 이어갈 수 있게 정리했다.
|
||||||
|
- [StreamingToolExecutionCoordinator.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs)
|
||||||
|
- read-only 도구 prefetch, tool-use 스트리밍 수신, partial tool-call 수집, context overflow/transient error 복구 루프를 별도 coordinator로 이동했다.
|
||||||
|
- [AgentLoopTransitions.Execution.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs)는 이제 thin wrapper로 coordinator를 호출하는 구조가 되어, 이후 `claw-code`식 executor 고도화를 더 쉽게 적용할 수 있다.
|
||||||
|
- [AgentLoopRuntimeThresholds.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopRuntimeThresholds.cs)
|
||||||
|
- no-tool response threshold, no-tool recovery max retries, plan execution retry max, terminal evidence gate max retries 계산을 별도 helper로 분리했다.
|
||||||
|
- [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)는 이제 환경 변수/설정값 임계치 계산 로직을 직접 들고 있지 않고 helper를 통해 소비한다.
|
||||||
|
- 구조 효과
|
||||||
|
- transcript 렌더링과 tool streaming 복구 정책의 책임 경계가 더 분명해졌다.
|
||||||
|
- 이후 남은 큰 작업인 `진짜 transcript 가상화`와 `AgentLoopService 추가 분해`를 더 작은 변경 단위로 진행할 수 있는 기반을 마련했다.
|
||||||
|
|||||||
52
src/AxCopilot/Services/Agent/AgentLoopRuntimeThresholds.cs
Normal file
52
src/AxCopilot/Services/Agent/AgentLoopRuntimeThresholds.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
namespace AxCopilot.Services.Agent;
|
||||||
|
|
||||||
|
internal static class AgentLoopRuntimeThresholds
|
||||||
|
{
|
||||||
|
public static int GetNoToolCallResponseThreshold(int defaultValue)
|
||||||
|
=> ResolveThresholdValue(
|
||||||
|
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RESPONSE_THRESHOLD"),
|
||||||
|
defaultValue,
|
||||||
|
min: 1,
|
||||||
|
max: 6);
|
||||||
|
|
||||||
|
public static int GetNoToolCallRecoveryMaxRetries(int defaultValue)
|
||||||
|
=> ResolveThresholdValue(
|
||||||
|
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RECOVERY_MAX_RETRIES"),
|
||||||
|
defaultValue,
|
||||||
|
min: 0,
|
||||||
|
max: 6);
|
||||||
|
|
||||||
|
public static int GetPlanExecutionRetryMax(int defaultValue)
|
||||||
|
=> ResolveThresholdValue(
|
||||||
|
Environment.GetEnvironmentVariable("AXCOPILOT_PLAN_EXECUTION_RETRY_MAX"),
|
||||||
|
defaultValue,
|
||||||
|
min: 0,
|
||||||
|
max: 6);
|
||||||
|
|
||||||
|
public static int GetTerminalEvidenceGateMaxRetries(int defaultValue)
|
||||||
|
=> ResolveThresholdValue(
|
||||||
|
Environment.GetEnvironmentVariable("AXCOPILOT_TERMINAL_EVIDENCE_GATE_MAX_RETRIES"),
|
||||||
|
defaultValue,
|
||||||
|
min: 0,
|
||||||
|
max: 3);
|
||||||
|
|
||||||
|
public static int ResolveNoToolCallResponseThreshold(string? envRaw)
|
||||||
|
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 1, max: 6);
|
||||||
|
|
||||||
|
public static int ResolveNoToolCallRecoveryMaxRetries(string? envRaw)
|
||||||
|
=> ResolveThresholdValue(envRaw, defaultValue: 3, min: 0, max: 6);
|
||||||
|
|
||||||
|
public static int ResolvePlanExecutionRetryMax(string? envRaw)
|
||||||
|
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 0, max: 6);
|
||||||
|
|
||||||
|
public static int ResolveTerminalEvidenceGateMaxRetries(string? envRaw)
|
||||||
|
=> ResolveThresholdValue(envRaw, defaultValue: 1, min: 0, max: 3);
|
||||||
|
|
||||||
|
private static int ResolveThresholdValue(string? raw, int defaultValue, int min, int max)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(raw, out var value))
|
||||||
|
return defaultValue;
|
||||||
|
|
||||||
|
return Math.Clamp(value, min, max);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ public partial class AgentLoopService
|
|||||||
private readonly LlmService _llm;
|
private readonly LlmService _llm;
|
||||||
private readonly ToolRegistry _tools;
|
private readonly ToolRegistry _tools;
|
||||||
private readonly SettingsService _settings;
|
private readonly SettingsService _settings;
|
||||||
|
private readonly StreamingToolExecutionCoordinator _toolExecutionCoordinator;
|
||||||
private readonly ConcurrentDictionary<string, PermissionPromptPreview> _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase);
|
private readonly ConcurrentDictionary<string, PermissionPromptPreview> _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
|
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
|
||||||
@@ -79,6 +80,21 @@ public partial class AgentLoopService
|
|||||||
_llm = llm;
|
_llm = llm;
|
||||||
_tools = tools;
|
_tools = tools;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
_toolExecutionCoordinator = new StreamingToolExecutionCoordinator(
|
||||||
|
_llm,
|
||||||
|
ResolveRequestedToolName,
|
||||||
|
async (toolName, input, context, messages, ct) =>
|
||||||
|
{
|
||||||
|
var tool = _tools.Get(toolName);
|
||||||
|
return tool == null
|
||||||
|
? ToolResult.Fail($"도구를 찾을 수 없습니다: {toolName}")
|
||||||
|
: await ExecuteToolWithTimeoutAsync(tool, toolName, input, context, messages, ct);
|
||||||
|
},
|
||||||
|
(eventType, toolName, summary) => EmitEvent(eventType, toolName, summary),
|
||||||
|
IsContextOverflowError,
|
||||||
|
ForceContextRecovery,
|
||||||
|
IsTransientLlmError,
|
||||||
|
ComputeTransientLlmBackoffDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TryGetPendingPermissionPreview(string toolName, string target, out PermissionPromptPreview? preview)
|
public bool TryGetPendingPermissionPreview(string toolName, string target, out PermissionPromptPreview? preview)
|
||||||
@@ -194,11 +210,11 @@ public partial class AgentLoopService
|
|||||||
var taskPolicy = TaskTypePolicy.FromTaskType(taskType);
|
var taskPolicy = TaskTypePolicy.FromTaskType(taskType);
|
||||||
var executionPolicy = _llm.GetActiveExecutionPolicy();
|
var executionPolicy = _llm.GetActiveExecutionPolicy();
|
||||||
var consecutiveNoToolResponses = 0;
|
var consecutiveNoToolResponses = 0;
|
||||||
var noToolResponseThreshold = GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold);
|
var noToolResponseThreshold = AgentLoopRuntimeThresholds.GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold);
|
||||||
var noToolRecoveryMaxRetries = GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries);
|
var noToolRecoveryMaxRetries = AgentLoopRuntimeThresholds.GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries);
|
||||||
var planExecutionRetryMax = GetPlanExecutionRetryMax(executionPolicy.PlanExecutionRetryMax);
|
var planExecutionRetryMax = AgentLoopRuntimeThresholds.GetPlanExecutionRetryMax(executionPolicy.PlanExecutionRetryMax);
|
||||||
var documentPlanRetryMax = Math.Max(0, executionPolicy.DocumentPlanRetryMax);
|
var documentPlanRetryMax = Math.Max(0, executionPolicy.DocumentPlanRetryMax);
|
||||||
var terminalEvidenceGateRetryMax = GetTerminalEvidenceGateMaxRetries(executionPolicy.TerminalEvidenceGateMaxRetries);
|
var terminalEvidenceGateRetryMax = AgentLoopRuntimeThresholds.GetTerminalEvidenceGateMaxRetries(executionPolicy.TerminalEvidenceGateMaxRetries);
|
||||||
var failedToolHistogram = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
var failedToolHistogram = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
var runState = new RunState();
|
var runState = new RunState();
|
||||||
var requireHighImpactCodeVerification = false;
|
var requireHighImpactCodeVerification = false;
|
||||||
@@ -3675,58 +3691,6 @@ public partial class AgentLoopService
|
|||||||
.Select(x => $"{x.Key}({x.Value})"));
|
.Select(x => $"{x.Key}({x.Value})"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int GetNoToolCallResponseThreshold()
|
|
||||||
=> GetNoToolCallResponseThreshold(defaultValue: 2);
|
|
||||||
|
|
||||||
private static int GetNoToolCallResponseThreshold(int defaultValue)
|
|
||||||
=> ResolveThresholdValue(
|
|
||||||
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RESPONSE_THRESHOLD"),
|
|
||||||
defaultValue,
|
|
||||||
min: 1,
|
|
||||||
max: 6);
|
|
||||||
|
|
||||||
private static int GetNoToolCallRecoveryMaxRetries()
|
|
||||||
=> GetNoToolCallRecoveryMaxRetries(defaultValue: 3);
|
|
||||||
|
|
||||||
private static int GetNoToolCallRecoveryMaxRetries(int defaultValue)
|
|
||||||
=> ResolveThresholdValue(
|
|
||||||
Environment.GetEnvironmentVariable("AXCOPILOT_NOTOOL_RECOVERY_MAX_RETRIES"),
|
|
||||||
defaultValue,
|
|
||||||
min: 0,
|
|
||||||
max: 6);
|
|
||||||
|
|
||||||
private static int GetPlanExecutionRetryMax()
|
|
||||||
=> GetPlanExecutionRetryMax(defaultValue: 2);
|
|
||||||
|
|
||||||
private static int GetPlanExecutionRetryMax(int defaultValue)
|
|
||||||
=> ResolveThresholdValue(
|
|
||||||
Environment.GetEnvironmentVariable("AXCOPILOT_PLAN_EXECUTION_RETRY_MAX"),
|
|
||||||
defaultValue,
|
|
||||||
min: 0,
|
|
||||||
max: 6);
|
|
||||||
|
|
||||||
private static int ResolveNoToolCallResponseThreshold(string? envRaw)
|
|
||||||
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 1, max: 6);
|
|
||||||
|
|
||||||
private static int ResolveNoToolCallRecoveryMaxRetries(string? envRaw)
|
|
||||||
=> ResolveThresholdValue(envRaw, defaultValue: 3, min: 0, max: 6);
|
|
||||||
|
|
||||||
private static int ResolvePlanExecutionRetryMax(string? envRaw)
|
|
||||||
=> ResolveThresholdValue(envRaw, defaultValue: 2, min: 0, max: 6);
|
|
||||||
|
|
||||||
private static int GetTerminalEvidenceGateMaxRetries()
|
|
||||||
=> GetTerminalEvidenceGateMaxRetries(defaultValue: 1);
|
|
||||||
|
|
||||||
private static int GetTerminalEvidenceGateMaxRetries(int defaultValue)
|
|
||||||
=> ResolveThresholdValue(
|
|
||||||
Environment.GetEnvironmentVariable("AXCOPILOT_TERMINAL_EVIDENCE_GATE_MAX_RETRIES"),
|
|
||||||
defaultValue,
|
|
||||||
min: 0,
|
|
||||||
max: 3);
|
|
||||||
|
|
||||||
private static int ResolveTerminalEvidenceGateMaxRetries(string? envRaw)
|
|
||||||
=> ResolveThresholdValue(envRaw, defaultValue: 1, min: 0, max: 3);
|
|
||||||
|
|
||||||
private static string BuildUnknownToolRecoveryPrompt(
|
private static string BuildUnknownToolRecoveryPrompt(
|
||||||
string unknownToolName,
|
string unknownToolName,
|
||||||
IReadOnlyCollection<string> activeToolNames)
|
IReadOnlyCollection<string> activeToolNames)
|
||||||
|
|||||||
@@ -1110,53 +1110,12 @@ public partial class AgentLoopService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly HashSet<string> PrefetchableReadOnlyTools = new(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
"file_read", "glob", "grep", "grep_tool", "folder_map", "document_read",
|
|
||||||
"search_codebase", "code_search", "env_tool", "datetime_tool",
|
|
||||||
"dev_env_detect", "memory", "json_tool", "regex_tool", "base64_tool",
|
|
||||||
"hash_tool", "image_analyze", "multi_read"
|
|
||||||
};
|
|
||||||
|
|
||||||
private async Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
|
private async Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
|
||||||
LlmService.ContentBlock block,
|
LlmService.ContentBlock block,
|
||||||
IReadOnlyCollection<IAgentTool> tools,
|
IReadOnlyCollection<IAgentTool> tools,
|
||||||
AgentContext context,
|
AgentContext context,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
=> await _toolExecutionCoordinator.TryPrefetchReadOnlyToolAsync(block, tools, context, ct);
|
||||||
var activeToolNames = tools.Select(t => t.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
|
||||||
var resolvedToolName = ResolveRequestedToolName(block.ToolName, activeToolNames);
|
|
||||||
block.ResolvedToolName = resolvedToolName;
|
|
||||||
|
|
||||||
if (!PrefetchableReadOnlyTools.Contains(resolvedToolName))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var tool = _tools.Get(resolvedToolName);
|
|
||||||
if (tool == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
EmitEvent(
|
|
||||||
AgentEventType.Thinking,
|
|
||||||
resolvedToolName,
|
|
||||||
$"읽기 도구 조기 실행 준비: {resolvedToolName}");
|
|
||||||
|
|
||||||
var sw = Stopwatch.StartNew();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var input = block.ToolInput ?? JsonDocument.Parse("{}").RootElement;
|
|
||||||
var result = await ExecuteToolWithTimeoutAsync(tool, resolvedToolName, input, context, null, ct);
|
|
||||||
sw.Stop();
|
|
||||||
return new LlmService.ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
sw.Stop();
|
|
||||||
return new LlmService.ToolPrefetchResult(
|
|
||||||
ToolResult.Fail($"조기 실행 오류: {ex.Message}"),
|
|
||||||
sw.ElapsedMilliseconds,
|
|
||||||
resolvedToolName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync(
|
private async Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync(
|
||||||
List<ChatMessage> messages,
|
List<ChatMessage> messages,
|
||||||
@@ -1167,75 +1126,15 @@ public partial class AgentLoopService
|
|||||||
bool forceToolCall = false,
|
bool forceToolCall = false,
|
||||||
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null,
|
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null,
|
||||||
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null)
|
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null)
|
||||||
{
|
=> await _toolExecutionCoordinator.SendWithToolsWithRecoveryAsync(
|
||||||
var transientRetries = runState?.TransientLlmErrorRetries ?? 0;
|
messages,
|
||||||
var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0;
|
tools,
|
||||||
while (true)
|
ct,
|
||||||
{
|
phaseLabel,
|
||||||
try
|
runState,
|
||||||
{
|
forceToolCall,
|
||||||
if (onStreamEventAsync == null)
|
prefetchToolCallAsync,
|
||||||
return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync);
|
onStreamEventAsync);
|
||||||
|
|
||||||
var blocks = new List<LlmService.ContentBlock>();
|
|
||||||
var textBuilder = new StringBuilder();
|
|
||||||
await foreach (var evt in _llm.StreamWithToolsAsync(messages, tools, forceToolCall, prefetchToolCallAsync, ct).WithCancellation(ct))
|
|
||||||
{
|
|
||||||
await onStreamEventAsync(evt);
|
|
||||||
if (evt.Kind == LlmService.ToolStreamEventKind.TextDelta && !string.IsNullOrWhiteSpace(evt.Text))
|
|
||||||
{
|
|
||||||
textBuilder.Append(evt.Text);
|
|
||||||
}
|
|
||||||
else if (evt.Kind == LlmService.ToolStreamEventKind.ToolCallReady && evt.ToolCall != null)
|
|
||||||
{
|
|
||||||
blocks.Add(evt.ToolCall);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = new List<LlmService.ContentBlock>();
|
|
||||||
var text = textBuilder.ToString().Trim();
|
|
||||||
if (!string.IsNullOrWhiteSpace(text))
|
|
||||||
result.Add(new LlmService.ContentBlock { Type = "text", Text = text });
|
|
||||||
result.AddRange(blocks);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
if (IsContextOverflowError(ex.Message)
|
|
||||||
&& contextRecoveryRetries < 2
|
|
||||||
&& ForceContextRecovery(messages))
|
|
||||||
{
|
|
||||||
contextRecoveryRetries++;
|
|
||||||
if (runState != null)
|
|
||||||
runState.ContextRecoveryAttempts = contextRecoveryRetries;
|
|
||||||
EmitEvent(
|
|
||||||
AgentEventType.Thinking,
|
|
||||||
"",
|
|
||||||
$"{phaseLabel}: 컨텍스트 한도 초과로 대화를 압축한 후 재시도합니다 ({contextRecoveryRetries}/2)");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 사용자 취소(ct)인 경우 재시도하지 않고 즉시 전파
|
|
||||||
if (ct.IsCancellationRequested) throw;
|
|
||||||
|
|
||||||
if (IsTransientLlmError(ex) && transientRetries < 3)
|
|
||||||
{
|
|
||||||
transientRetries++;
|
|
||||||
if (runState != null)
|
|
||||||
runState.TransientLlmErrorRetries = transientRetries;
|
|
||||||
var delayMs = ComputeTransientLlmBackoffDelayMs(transientRetries, ex);
|
|
||||||
EmitEvent(
|
|
||||||
AgentEventType.Thinking,
|
|
||||||
"",
|
|
||||||
$"{phaseLabel}: 일시적 LLM 오류로 {delayMs}ms 후 재시도합니다 ({transientRetries}/3)");
|
|
||||||
await Task.Delay(delayMs, ct);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplyToolPostExecutionBookkeeping(
|
private void ApplyToolPostExecutionBookkeeping(
|
||||||
LlmService.ContentBlock call,
|
LlmService.ContentBlock call,
|
||||||
@@ -1651,7 +1550,7 @@ public partial class AgentLoopService
|
|||||||
return (false, null);
|
return (false, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class RunState
|
internal sealed class RunState
|
||||||
{
|
{
|
||||||
public int ContextRecoveryAttempts;
|
public int ContextRecoveryAttempts;
|
||||||
public int WithheldRecoveryAttempts;
|
public int WithheldRecoveryAttempts;
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using AxCopilot.Models;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
|
||||||
|
namespace AxCopilot.Services.Agent;
|
||||||
|
|
||||||
|
internal sealed class StreamingToolExecutionCoordinator
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> PrefetchableReadOnlyTools = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"file_read", "glob", "grep", "grep_tool", "folder_map", "document_read",
|
||||||
|
"search_codebase", "code_search", "env_tool", "datetime_tool",
|
||||||
|
"dev_env_detect", "memory", "json_tool", "regex_tool", "base64_tool",
|
||||||
|
"hash_tool", "image_analyze", "multi_read"
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly LlmService _llm;
|
||||||
|
private readonly Func<string, IReadOnlyCollection<string>, string> _resolveRequestedToolName;
|
||||||
|
private readonly Func<string, JsonElement, AgentContext, List<ChatMessage>?, CancellationToken, Task<ToolResult>> _executeToolAsync;
|
||||||
|
private readonly Action<AgentEventType, string, string> _emitEvent;
|
||||||
|
private readonly Func<string?, bool> _isContextOverflowError;
|
||||||
|
private readonly Func<List<ChatMessage>, bool> _forceContextRecovery;
|
||||||
|
private readonly Func<Exception, bool> _isTransientLlmError;
|
||||||
|
private readonly Func<int, Exception, int> _computeTransientBackoffDelayMs;
|
||||||
|
|
||||||
|
public StreamingToolExecutionCoordinator(
|
||||||
|
LlmService llm,
|
||||||
|
Func<string, IReadOnlyCollection<string>, string> resolveRequestedToolName,
|
||||||
|
Func<string, JsonElement, AgentContext, List<ChatMessage>?, CancellationToken, Task<ToolResult>> executeToolAsync,
|
||||||
|
Action<AgentEventType, string, string> emitEvent,
|
||||||
|
Func<string?, bool> isContextOverflowError,
|
||||||
|
Func<List<ChatMessage>, bool> forceContextRecovery,
|
||||||
|
Func<Exception, bool> isTransientLlmError,
|
||||||
|
Func<int, Exception, int> computeTransientBackoffDelayMs)
|
||||||
|
{
|
||||||
|
_llm = llm;
|
||||||
|
_resolveRequestedToolName = resolveRequestedToolName;
|
||||||
|
_executeToolAsync = executeToolAsync;
|
||||||
|
_emitEvent = emitEvent;
|
||||||
|
_isContextOverflowError = isContextOverflowError;
|
||||||
|
_forceContextRecovery = forceContextRecovery;
|
||||||
|
_isTransientLlmError = isTransientLlmError;
|
||||||
|
_computeTransientBackoffDelayMs = computeTransientBackoffDelayMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LlmService.ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
|
||||||
|
LlmService.ContentBlock block,
|
||||||
|
IReadOnlyCollection<IAgentTool> tools,
|
||||||
|
AgentContext context,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var activeToolNames = tools.Select(t => t.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
var resolvedToolName = _resolveRequestedToolName(block.ToolName, activeToolNames);
|
||||||
|
block.ResolvedToolName = resolvedToolName;
|
||||||
|
|
||||||
|
if (!PrefetchableReadOnlyTools.Contains(resolvedToolName))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
_emitEvent(
|
||||||
|
AgentEventType.Thinking,
|
||||||
|
resolvedToolName,
|
||||||
|
$"읽기 도구 조기 실행 준비: {resolvedToolName}");
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var input = block.ToolInput ?? JsonDocument.Parse("{}").RootElement;
|
||||||
|
var result = await _executeToolAsync(resolvedToolName, input, context, null, ct);
|
||||||
|
sw.Stop();
|
||||||
|
return new LlmService.ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return new LlmService.ToolPrefetchResult(
|
||||||
|
ToolResult.Fail($"조기 실행 오류: {ex.Message}"),
|
||||||
|
sw.ElapsedMilliseconds,
|
||||||
|
resolvedToolName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<LlmService.ContentBlock>> SendWithToolsWithRecoveryAsync(
|
||||||
|
List<ChatMessage> messages,
|
||||||
|
IReadOnlyCollection<IAgentTool> tools,
|
||||||
|
CancellationToken ct,
|
||||||
|
string phaseLabel,
|
||||||
|
AgentLoopService.RunState? runState = null,
|
||||||
|
bool forceToolCall = false,
|
||||||
|
Func<LlmService.ContentBlock, Task<LlmService.ToolPrefetchResult?>>? prefetchToolCallAsync = null,
|
||||||
|
Func<LlmService.ToolStreamEvent, Task>? onStreamEventAsync = null)
|
||||||
|
{
|
||||||
|
var transientRetries = runState?.TransientLlmErrorRetries ?? 0;
|
||||||
|
var contextRecoveryRetries = runState?.ContextRecoveryAttempts ?? 0;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (onStreamEventAsync == null)
|
||||||
|
return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync);
|
||||||
|
|
||||||
|
var blocks = new List<LlmService.ContentBlock>();
|
||||||
|
var textBuilder = new StringBuilder();
|
||||||
|
await foreach (var evt in _llm.StreamWithToolsAsync(messages, tools, forceToolCall, prefetchToolCallAsync, ct).WithCancellation(ct))
|
||||||
|
{
|
||||||
|
await onStreamEventAsync(evt);
|
||||||
|
if (evt.Kind == LlmService.ToolStreamEventKind.TextDelta && !string.IsNullOrWhiteSpace(evt.Text))
|
||||||
|
{
|
||||||
|
textBuilder.Append(evt.Text);
|
||||||
|
}
|
||||||
|
else if (evt.Kind == LlmService.ToolStreamEventKind.ToolCallReady && evt.ToolCall != null)
|
||||||
|
{
|
||||||
|
blocks.Add(evt.ToolCall);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<LlmService.ContentBlock>();
|
||||||
|
var text = textBuilder.ToString().Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(text))
|
||||||
|
result.Add(new LlmService.ContentBlock { Type = "text", Text = text });
|
||||||
|
result.AddRange(blocks);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (_isContextOverflowError(ex.Message)
|
||||||
|
&& contextRecoveryRetries < 2
|
||||||
|
&& _forceContextRecovery(messages))
|
||||||
|
{
|
||||||
|
contextRecoveryRetries++;
|
||||||
|
if (runState != null)
|
||||||
|
runState.ContextRecoveryAttempts = contextRecoveryRetries;
|
||||||
|
|
||||||
|
_emitEvent(
|
||||||
|
AgentEventType.Thinking,
|
||||||
|
"",
|
||||||
|
$"{phaseLabel}: 컨텍스트 한도 초과로 대화를 압축한 후 재시도합니다 ({contextRecoveryRetries}/2)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ct.IsCancellationRequested)
|
||||||
|
throw;
|
||||||
|
|
||||||
|
if (_isTransientLlmError(ex) && transientRetries < 3)
|
||||||
|
{
|
||||||
|
transientRetries++;
|
||||||
|
if (runState != null)
|
||||||
|
runState.TransientLlmErrorRetries = transientRetries;
|
||||||
|
|
||||||
|
var delayMs = _computeTransientBackoffDelayMs(transientRetries, ex);
|
||||||
|
_emitEvent(
|
||||||
|
AgentEventType.Thinking,
|
||||||
|
"",
|
||||||
|
$"{phaseLabel}: 일시적 LLM 오류로 {delayMs}ms 후 재시도합니다 ({transientRetries}/3)");
|
||||||
|
await Task.Delay(delayMs, ct);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs
Normal file
169
src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
using System.Windows.Threading;
|
||||||
|
using AxCopilot.Models;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
|
||||||
|
namespace AxCopilot.Views;
|
||||||
|
|
||||||
|
public partial class ChatWindow
|
||||||
|
{
|
||||||
|
private int GetActiveTimelineRenderLimit()
|
||||||
|
{
|
||||||
|
if (!_isStreaming)
|
||||||
|
return _timelineRenderLimit;
|
||||||
|
|
||||||
|
var streamingLimit = IsLightweightLiveProgressMode()
|
||||||
|
? TimelineLightweightStreamingRenderLimit
|
||||||
|
: TimelineStreamingRenderLimit;
|
||||||
|
return Math.Min(_timelineRenderLimit, streamingLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderMessages(bool preserveViewport = false)
|
||||||
|
{
|
||||||
|
var previousScrollableHeight = GetTranscriptScrollableHeight();
|
||||||
|
var previousVerticalOffset = GetTranscriptVerticalOffset();
|
||||||
|
|
||||||
|
ChatConversation? conv;
|
||||||
|
lock (_convLock) conv = _currentConversation;
|
||||||
|
_appState.RestoreAgentRunHistory(conv?.AgentRunHistory);
|
||||||
|
|
||||||
|
var visibleMessages = GetVisibleTimelineMessages(conv);
|
||||||
|
var visibleEvents = GetVisibleTimelineEvents(conv);
|
||||||
|
|
||||||
|
if (_isStreaming && preserveViewport
|
||||||
|
&& visibleMessages.Count == _lastRenderedMessageCount
|
||||||
|
&& visibleEvents.Count == _lastRenderedEventCount
|
||||||
|
&& (conv?.ShowExecutionHistory ?? true) == _lastRenderedShowHistory
|
||||||
|
&& string.Equals(_lastRenderedConversationId, conv?.Id, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (conv == null || (visibleMessages.Count == 0 && visibleEvents.Count == 0))
|
||||||
|
{
|
||||||
|
ClearTranscriptElements();
|
||||||
|
_runBannerAnchors.Clear();
|
||||||
|
_lastRenderedTimelineKeys.Clear();
|
||||||
|
_lastRenderedMessageCount = 0;
|
||||||
|
_lastRenderedEventCount = 0;
|
||||||
|
EmptyState.Visibility = System.Windows.Visibility.Visible;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(_lastRenderedConversationId, conv.Id, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_lastRenderedConversationId = conv.Id;
|
||||||
|
_timelineRenderLimit = TimelineRenderPageSize;
|
||||||
|
_elementCache.Clear();
|
||||||
|
_lastRenderedTimelineKeys.Clear();
|
||||||
|
_lastRenderedMessageCount = 0;
|
||||||
|
_lastRenderedEventCount = 0;
|
||||||
|
InvalidateTimelineCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
var showHistory = conv.ShowExecutionHistory;
|
||||||
|
EmptyState.Visibility = System.Windows.Visibility.Collapsed;
|
||||||
|
|
||||||
|
var orderedTimeline = BuildTimelineRenderActions(visibleMessages, visibleEvents);
|
||||||
|
var effectiveRenderLimit = GetActiveTimelineRenderLimit();
|
||||||
|
var hiddenCount = Math.Max(0, orderedTimeline.Count - effectiveRenderLimit);
|
||||||
|
var visibleTimeline = hiddenCount > 0
|
||||||
|
? orderedTimeline.GetRange(hiddenCount, orderedTimeline.Count - hiddenCount)
|
||||||
|
: orderedTimeline;
|
||||||
|
var newKeys = new List<string>(visibleTimeline.Count);
|
||||||
|
foreach (var t in visibleTimeline)
|
||||||
|
newKeys.Add(t.Key);
|
||||||
|
|
||||||
|
var incremented = false;
|
||||||
|
var hasExternalChildren = _agentLiveContainer != null && ContainsTranscriptElement(_agentLiveContainer);
|
||||||
|
var expectedChildCount = _lastRenderedTimelineKeys.Count + (_lastRenderedHiddenCount > 0 ? 1 : 0);
|
||||||
|
var canIncremental = !hasExternalChildren
|
||||||
|
&& _lastRenderedTimelineKeys.Count > 0
|
||||||
|
&& newKeys.Count >= _lastRenderedTimelineKeys.Count
|
||||||
|
&& _lastRenderedHiddenCount == hiddenCount
|
||||||
|
&& GetTranscriptElementCount() == expectedChildCount;
|
||||||
|
|
||||||
|
if (canIncremental)
|
||||||
|
{
|
||||||
|
var prevLiveCount = 0;
|
||||||
|
for (var i = _lastRenderedTimelineKeys.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (_lastRenderedTimelineKeys[i].StartsWith("_live_", StringComparison.Ordinal))
|
||||||
|
prevLiveCount++;
|
||||||
|
else
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var prevStableCount = _lastRenderedTimelineKeys.Count - prevLiveCount;
|
||||||
|
var prefixMatch = true;
|
||||||
|
for (var i = 0; i < prevStableCount; i++)
|
||||||
|
{
|
||||||
|
if (i >= newKeys.Count || !string.Equals(_lastRenderedTimelineKeys[i], newKeys[i], StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
prefixMatch = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefixMatch)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (var r = 0; r < prevLiveCount && GetTranscriptElementCount() > 0; r++)
|
||||||
|
RemoveTranscriptElementAt(GetTranscriptElementCount() - 1);
|
||||||
|
|
||||||
|
for (var i = prevStableCount; i < visibleTimeline.Count; i++)
|
||||||
|
visibleTimeline[i].Render();
|
||||||
|
|
||||||
|
_lastRenderedTimelineKeys = newKeys;
|
||||||
|
_lastRenderedHiddenCount = hiddenCount;
|
||||||
|
_lastRenderedMessageCount = visibleMessages.Count;
|
||||||
|
_lastRenderedEventCount = visibleEvents.Count;
|
||||||
|
_lastRenderedShowHistory = showHistory;
|
||||||
|
incremented = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogService.Warn($"증분 렌더링 실패, 전체 재빌드로 전환: {ex.Message}");
|
||||||
|
_lastRenderedTimelineKeys.Clear();
|
||||||
|
incremented = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!incremented)
|
||||||
|
{
|
||||||
|
ClearTranscriptElements();
|
||||||
|
_runBannerAnchors.Clear();
|
||||||
|
|
||||||
|
if (hiddenCount > 0)
|
||||||
|
AddTranscriptElement(CreateTimelineLoadMoreCard(hiddenCount));
|
||||||
|
|
||||||
|
foreach (var item in visibleTimeline)
|
||||||
|
item.Render();
|
||||||
|
|
||||||
|
if (_agentLiveContainer != null && !ContainsTranscriptElement(_agentLiveContainer))
|
||||||
|
AddTranscriptElement(_agentLiveContainer);
|
||||||
|
|
||||||
|
_lastRenderedTimelineKeys = newKeys;
|
||||||
|
_lastRenderedHiddenCount = hiddenCount;
|
||||||
|
_lastRenderedMessageCount = visibleMessages.Count;
|
||||||
|
_lastRenderedEventCount = visibleEvents.Count;
|
||||||
|
_lastRenderedShowHistory = showHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preserveViewport)
|
||||||
|
{
|
||||||
|
_ = Dispatcher.InvokeAsync(ScrollTranscriptToEnd, DispatcherPriority.Background);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (_transcriptScrollViewer == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var newScrollableHeight = GetTranscriptScrollableHeight();
|
||||||
|
var delta = newScrollableHeight - previousScrollableHeight;
|
||||||
|
var targetOffset = Math.Max(0, previousVerticalOffset + Math.Max(0, delta));
|
||||||
|
ScrollTranscriptToVerticalOffset(targetOffset);
|
||||||
|
}, DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2284,181 +2284,6 @@ public partial class ChatWindow : Window
|
|||||||
private string? _lastRenderedConversationId;
|
private string? _lastRenderedConversationId;
|
||||||
private int _timelineRenderLimit = TimelineRenderPageSize;
|
private int _timelineRenderLimit = TimelineRenderPageSize;
|
||||||
|
|
||||||
private int GetActiveTimelineRenderLimit()
|
|
||||||
{
|
|
||||||
if (!_isStreaming)
|
|
||||||
return _timelineRenderLimit;
|
|
||||||
|
|
||||||
var streamingLimit = IsLightweightLiveProgressMode()
|
|
||||||
? TimelineLightweightStreamingRenderLimit
|
|
||||||
: TimelineStreamingRenderLimit;
|
|
||||||
return Math.Min(_timelineRenderLimit, streamingLimit);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RenderMessages(bool preserveViewport = false)
|
|
||||||
{
|
|
||||||
var previousScrollableHeight = GetTranscriptScrollableHeight();
|
|
||||||
var previousVerticalOffset = GetTranscriptVerticalOffset();
|
|
||||||
|
|
||||||
ChatConversation? conv;
|
|
||||||
lock (_convLock) conv = _currentConversation;
|
|
||||||
_appState.RestoreAgentRunHistory(conv?.AgentRunHistory);
|
|
||||||
|
|
||||||
var visibleMessages = GetVisibleTimelineMessages(conv);
|
|
||||||
var visibleEvents = GetVisibleTimelineEvents(conv);
|
|
||||||
|
|
||||||
// 스트리밍 중 메시지·이벤트 수가 동일하면 불필요한 재렌더링 스킵
|
|
||||||
if (_isStreaming && preserveViewport
|
|
||||||
&& visibleMessages.Count == _lastRenderedMessageCount
|
|
||||||
&& visibleEvents.Count == _lastRenderedEventCount
|
|
||||||
&& (conv?.ShowExecutionHistory ?? true) == _lastRenderedShowHistory
|
|
||||||
&& string.Equals(_lastRenderedConversationId, conv?.Id, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (conv == null || (visibleMessages.Count == 0 && visibleEvents.Count == 0))
|
|
||||||
{
|
|
||||||
ClearTranscriptElements();
|
|
||||||
_runBannerAnchors.Clear();
|
|
||||||
_lastRenderedTimelineKeys.Clear();
|
|
||||||
_lastRenderedMessageCount = 0;
|
|
||||||
_lastRenderedEventCount = 0;
|
|
||||||
EmptyState.Visibility = Visibility.Visible;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.Equals(_lastRenderedConversationId, conv.Id, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
_lastRenderedConversationId = conv.Id;
|
|
||||||
_timelineRenderLimit = TimelineRenderPageSize;
|
|
||||||
_elementCache.Clear();
|
|
||||||
_lastRenderedTimelineKeys.Clear();
|
|
||||||
_lastRenderedMessageCount = 0;
|
|
||||||
_lastRenderedEventCount = 0;
|
|
||||||
InvalidateTimelineCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
var showHistory = conv.ShowExecutionHistory;
|
|
||||||
|
|
||||||
EmptyState.Visibility = Visibility.Collapsed;
|
|
||||||
|
|
||||||
var orderedTimeline = BuildTimelineRenderActions(visibleMessages, visibleEvents);
|
|
||||||
var effectiveRenderLimit = GetActiveTimelineRenderLimit();
|
|
||||||
var hiddenCount = Math.Max(0, orderedTimeline.Count - effectiveRenderLimit);
|
|
||||||
var visibleTimeline = hiddenCount > 0
|
|
||||||
? orderedTimeline.GetRange(hiddenCount, orderedTimeline.Count - hiddenCount)
|
|
||||||
: orderedTimeline;
|
|
||||||
var newKeys = new List<string>(visibleTimeline.Count);
|
|
||||||
foreach (var t in visibleTimeline) newKeys.Add(t.Key);
|
|
||||||
|
|
||||||
var incremented = false;
|
|
||||||
|
|
||||||
// ── 증분 렌더링: 이전 키 목록과 비교하여 변경 최소화 ──
|
|
||||||
// agent live card가 존재하면 Children과 키 불일치 가능 → 전체 재빌드
|
|
||||||
var hasExternalChildren = _agentLiveContainer != null && ContainsTranscriptElement(_agentLiveContainer);
|
|
||||||
var expectedChildCount = _lastRenderedTimelineKeys.Count + (_lastRenderedHiddenCount > 0 ? 1 : 0);
|
|
||||||
var canIncremental = !hasExternalChildren
|
|
||||||
&& _lastRenderedTimelineKeys.Count > 0
|
|
||||||
&& newKeys.Count >= _lastRenderedTimelineKeys.Count
|
|
||||||
&& _lastRenderedHiddenCount == hiddenCount
|
|
||||||
&& GetTranscriptElementCount() == expectedChildCount;
|
|
||||||
|
|
||||||
if (canIncremental)
|
|
||||||
{
|
|
||||||
// _live_ 키 개수를 한 번만 계산 (이전 키 목록에서)
|
|
||||||
var prevLiveCount = 0;
|
|
||||||
for (int i = _lastRenderedTimelineKeys.Count - 1; i >= 0; i--)
|
|
||||||
{
|
|
||||||
if (_lastRenderedTimelineKeys[i].StartsWith("_live_", StringComparison.Ordinal))
|
|
||||||
prevLiveCount++;
|
|
||||||
else
|
|
||||||
break; // live 키는 항상 끝에 연속으로 위치
|
|
||||||
}
|
|
||||||
var prevStableCount = _lastRenderedTimelineKeys.Count - prevLiveCount;
|
|
||||||
|
|
||||||
// 안정 키(non-live) 접두사가 일치하는지 확인
|
|
||||||
var prefixMatch = true;
|
|
||||||
for (int i = 0; i < prevStableCount; i++)
|
|
||||||
{
|
|
||||||
if (i >= newKeys.Count || !string.Equals(_lastRenderedTimelineKeys[i], newKeys[i], StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
prefixMatch = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prefixMatch)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 이전 live 요소를 Children 끝에서 제거
|
|
||||||
for (int r = 0; r < prevLiveCount && GetTranscriptElementCount() > 0; r++)
|
|
||||||
RemoveTranscriptElementAt(GetTranscriptElementCount() - 1);
|
|
||||||
|
|
||||||
// 안정 접두사 이후의 새 아이템 렌더링 (새 stable + 새 live 포함)
|
|
||||||
for (int i = prevStableCount; i < visibleTimeline.Count; i++)
|
|
||||||
visibleTimeline[i].Render();
|
|
||||||
|
|
||||||
_lastRenderedTimelineKeys = newKeys;
|
|
||||||
_lastRenderedHiddenCount = hiddenCount;
|
|
||||||
_lastRenderedMessageCount = visibleMessages.Count;
|
|
||||||
_lastRenderedEventCount = visibleEvents.Count;
|
|
||||||
_lastRenderedShowHistory = showHistory;
|
|
||||||
incremented = true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
LogService.Warn($"증분 렌더링 실패, 전체 재빌드로 전환: {ex.Message}");
|
|
||||||
_lastRenderedTimelineKeys.Clear();
|
|
||||||
incremented = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!incremented)
|
|
||||||
{
|
|
||||||
// ── 전체 재빌드 (대화 전환, 접기/펴기, 중간 변경 등) ──
|
|
||||||
ClearTranscriptElements();
|
|
||||||
_runBannerAnchors.Clear();
|
|
||||||
|
|
||||||
if (hiddenCount > 0)
|
|
||||||
AddTranscriptElement(CreateTimelineLoadMoreCard(hiddenCount));
|
|
||||||
|
|
||||||
foreach (var item in visibleTimeline)
|
|
||||||
item.Render();
|
|
||||||
|
|
||||||
// 전체 재빌드로 Children이 초기화되었으므로 agent live card 복원
|
|
||||||
if (_agentLiveContainer != null && !ContainsTranscriptElement(_agentLiveContainer))
|
|
||||||
AddTranscriptElement(_agentLiveContainer);
|
|
||||||
|
|
||||||
_lastRenderedTimelineKeys = newKeys;
|
|
||||||
_lastRenderedHiddenCount = hiddenCount;
|
|
||||||
_lastRenderedMessageCount = visibleMessages.Count;
|
|
||||||
_lastRenderedEventCount = visibleEvents.Count;
|
|
||||||
_lastRenderedShowHistory = showHistory;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 스크롤 처리 ──
|
|
||||||
if (!preserveViewport)
|
|
||||||
{
|
|
||||||
_ = Dispatcher.InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
ScrollTranscriptToEnd();
|
|
||||||
}, DispatcherPriority.Background);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = Dispatcher.InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
if (_transcriptScrollViewer == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var newScrollableHeight = GetTranscriptScrollableHeight();
|
|
||||||
var delta = newScrollableHeight - previousScrollableHeight;
|
|
||||||
var targetOffset = Math.Max(0, previousVerticalOffset + Math.Max(0, delta));
|
|
||||||
ScrollTranscriptToVerticalOffset(targetOffset);
|
|
||||||
}, DispatcherPriority.Background);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 커스텀 체크 아이콘 (모든 팝업 메뉴 공통) ─────────────────────────
|
// ─── 커스텀 체크 아이콘 (모든 팝업 메뉴 공통) ─────────────────────────
|
||||||
|
|
||||||
/// <summary>커스텀 체크/미선택 아이콘을 생성합니다. Path 도형 기반, 선택 시 스케일 바운스 애니메이션.</summary>
|
/// <summary>커스텀 체크/미선택 아이콘을 생성합니다. Path 도형 기반, 선택 시 스케일 바운스 애니메이션.</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user