diff --git a/README.md b/README.md index 05f8078..50bf13f 100644 --- a/README.md +++ b/README.md @@ -1521,3 +1521,9 @@ MIT License - [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.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 진짜 가상화와 모델별 실행 정책 조정도 덜 위험하게 진행할 수 있는 구조가 됐습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ca3e8e4..86ce3c9 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -5503,3 +5503,20 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎. - 실행 중 `Children.Add/Remove`에 직접 매달리던 경로를 줄여 레이아웃 churn을 완화했다. - `claw-code`의 `VirtualMessageList`처럼 완전한 전면 가상화는 아니지만, 그 단계로 가기 위한 transcript host 분리를 마쳤다. - 이후 단계에서는 이 컬렉션 호스트 위에 실제 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 추가 분해`를 더 작은 변경 단위로 진행할 수 있는 기반을 마련했다. diff --git a/src/AxCopilot/Services/Agent/AgentLoopRuntimeThresholds.cs b/src/AxCopilot/Services/Agent/AgentLoopRuntimeThresholds.cs new file mode 100644 index 0000000..dd684cf --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopRuntimeThresholds.cs @@ -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); + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 3d6996e..e7b59fa 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -26,6 +26,7 @@ public partial class AgentLoopService private readonly LlmService _llm; private readonly ToolRegistry _tools; private readonly SettingsService _settings; + private readonly StreamingToolExecutionCoordinator _toolExecutionCoordinator; private readonly ConcurrentDictionary _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase); /// 에이전트 이벤트 스트림 (UI 바인딩용). @@ -79,6 +80,21 @@ public partial class AgentLoopService _llm = llm; _tools = tools; _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) @@ -194,11 +210,11 @@ public partial class AgentLoopService var taskPolicy = TaskTypePolicy.FromTaskType(taskType); var executionPolicy = _llm.GetActiveExecutionPolicy(); var consecutiveNoToolResponses = 0; - var noToolResponseThreshold = GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold); - var noToolRecoveryMaxRetries = GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries); - var planExecutionRetryMax = GetPlanExecutionRetryMax(executionPolicy.PlanExecutionRetryMax); + var noToolResponseThreshold = AgentLoopRuntimeThresholds.GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold); + var noToolRecoveryMaxRetries = AgentLoopRuntimeThresholds.GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries); + var planExecutionRetryMax = AgentLoopRuntimeThresholds.GetPlanExecutionRetryMax(executionPolicy.PlanExecutionRetryMax); var documentPlanRetryMax = Math.Max(0, executionPolicy.DocumentPlanRetryMax); - var terminalEvidenceGateRetryMax = GetTerminalEvidenceGateMaxRetries(executionPolicy.TerminalEvidenceGateMaxRetries); + var terminalEvidenceGateRetryMax = AgentLoopRuntimeThresholds.GetTerminalEvidenceGateMaxRetries(executionPolicy.TerminalEvidenceGateMaxRetries); var failedToolHistogram = new Dictionary(StringComparer.OrdinalIgnoreCase); var runState = new RunState(); var requireHighImpactCodeVerification = false; @@ -3675,58 +3691,6 @@ public partial class AgentLoopService .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( string unknownToolName, IReadOnlyCollection activeToolNames) diff --git a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs index bcb7126..0b6b7f0 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs @@ -1110,53 +1110,12 @@ public partial class AgentLoopService } } - private static readonly HashSet 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 TryPrefetchReadOnlyToolAsync( LlmService.ContentBlock block, IReadOnlyCollection 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; - - 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); - } - } + => await _toolExecutionCoordinator.TryPrefetchReadOnlyToolAsync(block, tools, context, ct); private async Task> SendWithToolsWithRecoveryAsync( List messages, @@ -1167,75 +1126,15 @@ public partial class AgentLoopService bool forceToolCall = false, Func>? prefetchToolCallAsync = null, Func? 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(); - 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(); - 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; - } - } - } + => await _toolExecutionCoordinator.SendWithToolsWithRecoveryAsync( + messages, + tools, + ct, + phaseLabel, + runState, + forceToolCall, + prefetchToolCallAsync, + onStreamEventAsync); private void ApplyToolPostExecutionBookkeeping( LlmService.ContentBlock call, @@ -1651,7 +1550,7 @@ public partial class AgentLoopService return (false, null); } - private sealed class RunState + internal sealed class RunState { public int ContextRecoveryAttempts; public int WithheldRecoveryAttempts; diff --git a/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs b/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs new file mode 100644 index 0000000..885ca9a --- /dev/null +++ b/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs @@ -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 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> _resolveRequestedToolName; + private readonly Func?, CancellationToken, Task> _executeToolAsync; + private readonly Action _emitEvent; + private readonly Func _isContextOverflowError; + private readonly Func, bool> _forceContextRecovery; + private readonly Func _isTransientLlmError; + private readonly Func _computeTransientBackoffDelayMs; + + public StreamingToolExecutionCoordinator( + LlmService llm, + Func, string> resolveRequestedToolName, + Func?, CancellationToken, Task> executeToolAsync, + Action emitEvent, + Func isContextOverflowError, + Func, bool> forceContextRecovery, + Func isTransientLlmError, + Func computeTransientBackoffDelayMs) + { + _llm = llm; + _resolveRequestedToolName = resolveRequestedToolName; + _executeToolAsync = executeToolAsync; + _emitEvent = emitEvent; + _isContextOverflowError = isContextOverflowError; + _forceContextRecovery = forceContextRecovery; + _isTransientLlmError = isTransientLlmError; + _computeTransientBackoffDelayMs = computeTransientBackoffDelayMs; + } + + public async Task TryPrefetchReadOnlyToolAsync( + LlmService.ContentBlock block, + IReadOnlyCollection 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> SendWithToolsWithRecoveryAsync( + List messages, + IReadOnlyCollection tools, + CancellationToken ct, + string phaseLabel, + AgentLoopService.RunState? runState = null, + bool forceToolCall = false, + Func>? prefetchToolCallAsync = null, + Func? 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(); + 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(); + 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; + } + } + } +} diff --git a/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs b/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs new file mode 100644 index 0000000..43039a7 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs @@ -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(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); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index f8ab625..e7d64ac 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -2284,181 +2284,6 @@ public partial class ChatWindow : Window private string? _lastRenderedConversationId; 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(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); - } - // ─── 커스텀 체크 아이콘 (모든 팝업 메뉴 공통) ───────────────────────── /// 커스텀 체크/미선택 아이콘을 생성합니다. Path 도형 기반, 선택 시 스케일 바운스 애니메이션.