diff --git a/README.md b/README.md index bb23f78..b252237 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,23 @@ # AX Commander +- Update: 2026-04-16 06:41 (KST) +- Continued the Code-tab structural refactor by extracting the dispatch/stream stage into [AgentLoopLlmDispatchStageService.cs](). +- The new dispatch stage now owns: + - workflow `query_context` / `llm_request` logging + - stream preview and tool-ready thinking updates + - read-only tool prefetch handoff + - the recovery-capable `SendWithToolsWithRecoveryAsync(...)` dispatch itself +- [AgentLoopService.cs]() now treats LLM dispatch as a staged service call instead of building the stream callback inline, which keeps the loop closer to the staged `claw-code` shape. +- [StreamingToolExecutionCoordinator.cs]() was also rewritten with English-only status strings so the active stream/wait path no longer carries mojibake text, and [AgentLoopPreLlmStageService.cs]() now returns clean English missing-tool failures. +- Added [AgentLoopLlmDispatchStageServiceTests.cs]() to lock: + - staged dispatch argument handoff + - run-state retry reset after a successful dispatch + - streaming preview / tool-ready / retry-reset event emission + - read-only prefetch delegation through the coordinator +- Validation: + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_llm_dispatch_stage_escalated2\\ -p:IntermediateOutputPath=obj\\verify_llm_dispatch_stage_escalated2\\` warnings 0 / errors 0 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopLlmDispatchStageServiceTests|AgentLoopPreLlmStageServiceTests|AgentLoopQueryAssemblyServiceTests|AgentLoopIterationPreparationServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentMessageInvariantHelperTests|CodeTaskWorkingSetServiceTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_llm_dispatch_stage_tests_escalated2\\ -p:IntermediateOutputPath=obj\\verify_llm_dispatch_stage_tests_escalated2\\` passed 43 + - Update: 2026-04-16 02:13 (KST) - Continued the Code-tab structural refactor by extracting the pre-LLM stage into [AgentLoopPreLlmStageService.cs](). - The new stage service now owns: diff --git a/docs/CODE_CONTEXT_RELIABILITY_PLAN.md b/docs/CODE_CONTEXT_RELIABILITY_PLAN.md index fe9ecc8..bf98dd8 100644 --- a/docs/CODE_CONTEXT_RELIABILITY_PLAN.md +++ b/docs/CODE_CONTEXT_RELIABILITY_PLAN.md @@ -1,5 +1,18 @@ # Code Context Reliability Plan +Update: 2026-04-16 06:41 (KST) + +- Added `src/AxCopilot/Services/Agent/AgentLoopLlmDispatchStageService.cs` so the LLM dispatch path is now split into: + - history/query assembly + - pre-LLM stage planning + - dispatch/stream stage execution + - tool execution and recovery +- `AgentLoopService` no longer owns the inline stream preview callback or the direct `SendWithToolsWithRecoveryAsync(...)` setup for the primary loop. +- `StreamingToolExecutionCoordinator.cs` was also normalized to English-only active-path status strings so the staged dispatch path no longer reintroduces mojibake text during wait/retry handling. +- Remaining structural gap versus the target `claw-code` shape: + - the `NotSupportedException` / `ToolCallNotSupportedException` fallback branch still lives in `AgentLoopService` + - the next extraction target should be a narrower fallback policy stage so the main loop keeps shrinking toward a pure orchestrator + Update: 2026-04-16 01:37 (KST) ## Background diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c9a1a7a..ebbe823 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1873,3 +1873,11 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫 - 검증: - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_pre_llm_stage_structure\\ -p:IntermediateOutputPath=obj\\verify_pre_llm_stage_structure\\` 경고 0 / 오류 0 - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopPreLlmStageServiceTests|AgentLoopQueryAssemblyServiceTests|AgentLoopIterationPreparationServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentMessageInvariantHelperTests|SessionLearningCollectorTests|CodeTaskWorkingSetServiceTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_pre_llm_stage_structure_tests\\ -p:IntermediateOutputPath=obj\\verify_pre_llm_stage_structure_tests\\` 통과 60 +업데이트: 2026-04-16 06:41 (KST) +- Code 탭 LLM 호출 경로를 한 단계 더 분리했습니다. `src/AxCopilot/Services/Agent/AgentLoopLlmDispatchStageService.cs`를 추가해 `query_context`/`llm_request` 워크플로 로그, 스트리밍 thinking 미리보기, read-only prefetch 연결, recovery-capable tool dispatch를 한 서비스로 모았습니다. +- `src/AxCopilot/Services/Agent/AgentLoopService.cs`는 이제 pre-LLM stage 결과를 받아 dispatch stage 서비스에 넘기고, 메인 루프는 오케스트레이션과 fallback 분기 중심으로 유지합니다. 이 과정에서 function-calling 미지원 fallback도 staged request(`code_working_set` 포함)를 재사용하도록 보정했습니다. +- `src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs`는 로직은 유지하되 깨진 상태 문자열을 모두 영어로 정리해 스트리밍 대기/재시도 경로에서 mojibake가 다시 나오지 않게 했고, `src/AxCopilot/Services/Agent/AgentLoopPreLlmStageService.cs`의 missing-tool 실패 문구도 영어로 정리했습니다. +- `src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs`의 `BuildQueryContextDetail(...)`는 dispatch stage에서도 재사용할 수 있게 내부 공개 범위로 조정했습니다. +- 테스트: `src/AxCopilot.Tests/Services/AgentLoopLlmDispatchStageServiceTests.cs` +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_llm_dispatch_stage_escalated2\\ -p:IntermediateOutputPath=obj\\verify_llm_dispatch_stage_escalated2\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopLlmDispatchStageServiceTests|AgentLoopPreLlmStageServiceTests|AgentLoopQueryAssemblyServiceTests|AgentLoopIterationPreparationServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentMessageInvariantHelperTests|CodeTaskWorkingSetServiceTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_llm_dispatch_stage_tests_escalated2\\ -p:IntermediateOutputPath=obj\\verify_llm_dispatch_stage_tests_escalated2\\` 통과 43 diff --git a/src/AxCopilot.Tests/Services/AgentLoopLlmDispatchStageServiceTests.cs b/src/AxCopilot.Tests/Services/AgentLoopLlmDispatchStageServiceTests.cs new file mode 100644 index 0000000..3738260 --- /dev/null +++ b/src/AxCopilot.Tests/Services/AgentLoopLlmDispatchStageServiceTests.cs @@ -0,0 +1,198 @@ +using System.Text.Json; +using AxCopilot.Models; +using AxCopilot.Services; +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class AgentLoopLlmDispatchStageServiceTests +{ + private sealed class FakeAgentTool : IAgentTool + { + public string Name { get; init; } = "file_read"; + public string Description { get; init; } = "Reads a file"; + public ToolParameterSchema Parameters { get; init; } = new(); + + public Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default) + => Task.FromResult(ToolResult.Ok("ok")); + } + + private sealed class FakeToolExecutionCoordinator : IToolExecutionCoordinator + { + public List? CapturedMessages { get; private set; } + public IReadOnlyCollection? CapturedTools { get; private set; } + public string? CapturedPhaseLabel { get; private set; } + public bool CapturedForceToolCall { get; private set; } + public AgentLoopService.RunState? CapturedRunState { get; private set; } + public int PrefetchCallCount { get; private set; } + public AgentContext? PrefetchContext { get; private set; } + public Func?, Func>?, Task>>? DispatchAsync { get; init; } + + public Task TryPrefetchReadOnlyToolAsync( + ContentBlock block, + IReadOnlyCollection tools, + AgentContext context, + CancellationToken ct) + { + PrefetchCallCount++; + PrefetchContext = context; + return Task.FromResult( + new ToolPrefetchResult(ToolResult.Ok("prefetched"), 5, block.ToolName)); + } + + 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) + { + CapturedMessages = messages; + CapturedTools = tools; + CapturedPhaseLabel = phaseLabel; + CapturedForceToolCall = forceToolCall; + CapturedRunState = runState; + + if (DispatchAsync != null) + return await DispatchAsync(onStreamEventAsync, prefetchToolCallAsync); + + return []; + } + } + + [Fact] + public async Task ExecuteAsync_ShouldDispatchPreparedRequestAndResetRunState() + { + var coordinator = new FakeToolExecutionCoordinator + { + DispatchAsync = (_, _) => Task.FromResult(new List + { + new() { Type = "tool_use", ToolName = "file_read", ToolId = "tool-1" } + }) + }; + var events = new List<(AgentEventType Type, string Tool, string Summary)>(); + var service = new AgentLoopLlmDispatchStageService( + coordinator, + block => $"{block.ToolName}:{block.ToolId}", + (type, tool, summary) => events.Add((type, tool, summary))); + + var runState = new AgentLoopService.RunState + { + ContextRecoveryAttempts = 2, + TransientLlmErrorRetries = 1, + }; + var request = new AgentLoopLlmRequestPreparationResult( + SendMessages: + [ + new ChatMessage { Role = "user", Content = "fix the build" } + ], + ForceInitialToolCall: true, + InjectedToolReminder: false, + SupplementalMessageCount: 0, + FlattenedStructuredAssistantCount: 0, + ConvertedOrphanToolResultCount: 0); + + var result = await service.ExecuteAsync( + new AgentLoopLlmDispatchStageInput( + Iteration: 3, + ConversationId: "conv-1", + RunId: "run-1", + PhaseLabel: "Main loop 3", + CurrentModel: "model-a", + QueryContextDetail: "context=ready", + LlmRequest: request, + ActiveTools: [new FakeAgentTool()], + Context: new AgentContext { WorkFolder = @"E:\code", ActiveTab = "Code" }, + RunState: runState), + CancellationToken.None); + + result.Blocks.Should().ContainSingle(); + coordinator.CapturedMessages.Should().BeSameAs(request.SendMessages); + coordinator.CapturedPhaseLabel.Should().Be("Main loop 3"); + coordinator.CapturedForceToolCall.Should().BeTrue(); + runState.ContextRecoveryAttempts.Should().Be(0); + runState.TransientLlmErrorRetries.Should().Be(0); + events.Should().BeEmpty(); + } + + [Fact] + public async Task ExecuteAsync_ShouldEmitStreamPreviewToolReadyAndRetryResetEvents() + { + var coordinator = new FakeToolExecutionCoordinator + { + DispatchAsync = async (onStreamEventAsync, prefetchToolCallAsync) => + { + using var readInput = JsonDocument.Parse("""{"path":"Views/MainWindow.xaml"}"""); + if (prefetchToolCallAsync != null) + { + await prefetchToolCallAsync(new ContentBlock + { + Type = "tool_use", + ToolName = "file_read", + ToolId = "prefetch-1", + ToolInput = readInput.RootElement.Clone(), + }); + } + + if (onStreamEventAsync != null) + { + await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.TextDelta, "Inspecting WPF files")); + await onStreamEventAsync(new ToolStreamEvent( + ToolStreamEventKind.ToolCallReady, + ToolCall: new ContentBlock + { + Type = "tool_use", + ToolName = "file_manage", + ToolId = "tool-9", + })); + await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, "retry")); + } + + return []; + } + }; + + var events = new List<(AgentEventType Type, string Tool, string Summary)>(); + var service = new AgentLoopLlmDispatchStageService( + coordinator, + block => $"{block.ToolName}:{block.ToolId}", + (type, tool, summary) => events.Add((type, tool, summary))); + + var request = new AgentLoopLlmRequestPreparationResult( + SendMessages: + [ + new ChatMessage { Role = "user", Content = "create WPF shell" } + ], + ForceInitialToolCall: false, + InjectedToolReminder: false, + SupplementalMessageCount: 0, + FlattenedStructuredAssistantCount: 0, + ConvertedOrphanToolResultCount: 0); + var context = new AgentContext { WorkFolder = @"E:\code", ActiveTab = "Code" }; + + await service.ExecuteAsync( + new AgentLoopLlmDispatchStageInput( + Iteration: 1, + ConversationId: "conv-2", + RunId: "run-2", + PhaseLabel: "Main loop 1", + CurrentModel: "model-b", + QueryContextDetail: "context=ready", + LlmRequest: request, + ActiveTools: [new FakeAgentTool(), new FakeAgentTool { Name = "file_manage" }], + Context: context, + RunState: new AgentLoopService.RunState()), + CancellationToken.None); + + coordinator.PrefetchCallCount.Should().Be(1); + coordinator.PrefetchContext.Should().BeSameAs(context); + events.Should().Contain(entry => entry.Type == AgentEventType.Thinking && entry.Summary.Contains("Inspecting WPF files")); + events.Should().Contain(entry => entry.Type == AgentEventType.Thinking && entry.Summary.Contains("Streaming tool ready: file_manage:tool-9")); + events.Should().Contain(entry => entry.Type == AgentEventType.Thinking && entry.Summary.Contains("Resetting the streamed partial response before retry.")); + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs b/src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs index ff11a53..2fc5f4f 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopContextReliability.cs @@ -7,7 +7,7 @@ public partial class AgentLoopService ? AgentQueryContextBuilder.AgentQueryContextBuildOptions.CreateCodeDefault() : AgentQueryContextBuilder.AgentQueryContextBuildOptions.CreateDefault(); - private static string BuildQueryContextDetail( + internal static string BuildQueryContextDetail( AgentQueryContextWindowResult queryView, AgentLoopLlmRequestPreparationResult llmRequest, CodeTaskWorkingSetService? codeWorkingSet) diff --git a/src/AxCopilot/Services/Agent/AgentLoopLlmDispatchStageService.cs b/src/AxCopilot/Services/Agent/AgentLoopLlmDispatchStageService.cs new file mode 100644 index 0000000..775e547 --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopLlmDispatchStageService.cs @@ -0,0 +1,152 @@ +using System.Diagnostics; +using System.Text; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Services.Agent; + +internal sealed record AgentLoopLlmDispatchStageInput( + int Iteration, + string ConversationId, + string RunId, + string PhaseLabel, + string CurrentModel, + string QueryContextDetail, + AgentLoopLlmRequestPreparationResult LlmRequest, + IReadOnlyCollection ActiveTools, + AgentContext Context, + AgentLoopService.RunState RunState); + +internal sealed record AgentLoopLlmDispatchStageResult( + List Blocks, + long ElapsedMilliseconds); + +/// +/// Executes the staged LLM dispatch for one loop iteration: +/// workflow logging, stream preview updates, read-only prefetch, and the +/// recovery-capable tool-calling request itself. +/// +internal sealed class AgentLoopLlmDispatchStageService +{ + private static readonly TimeSpan StreamPreviewUiInterval = TimeSpan.FromMilliseconds(450); + + private readonly IToolExecutionCoordinator _toolExecutionCoordinator; + private readonly Func _formatToolCallSummary; + private readonly Action _emitEvent; + + public AgentLoopLlmDispatchStageService( + IToolExecutionCoordinator toolExecutionCoordinator, + Func formatToolCallSummary, + Action emitEvent) + { + _toolExecutionCoordinator = toolExecutionCoordinator; + _formatToolCallSummary = formatToolCallSummary; + _emitEvent = emitEvent; + } + + public async Task ExecuteAsync( + AgentLoopLlmDispatchStageInput input, + CancellationToken ct) + { + WorkflowLogService.SetCallContext(input.ConversationId, input.RunId, input.Iteration); + WorkflowLogService.LogTransition( + input.ConversationId, + input.RunId, + input.Iteration, + "query_context", + input.QueryContextDetail); + WorkflowLogService.LogLlmRequest( + input.ConversationId, + input.RunId, + input.Iteration, + input.CurrentModel, + input.LlmRequest.SendMessages.Count, + input.ActiveTools.Count, + input.LlmRequest.ForceInitialToolCall); + + var streamPreviewObserver = new StreamPreviewObserver(_formatToolCallSummary, _emitEvent); + var dispatchStopwatch = Stopwatch.StartNew(); + var blocks = await _toolExecutionCoordinator.SendWithToolsWithRecoveryAsync( + input.LlmRequest.SendMessages, + input.ActiveTools, + ct, + input.PhaseLabel, + input.RunState, + forceToolCall: input.LlmRequest.ForceInitialToolCall, + prefetchToolCallAsync: block => _toolExecutionCoordinator.TryPrefetchReadOnlyToolAsync( + block, + input.ActiveTools, + input.Context, + ct), + onStreamEventAsync: streamPreviewObserver.HandleAsync).ConfigureAwait(false); + dispatchStopwatch.Stop(); + + input.RunState.ContextRecoveryAttempts = 0; + input.RunState.TransientLlmErrorRetries = 0; + + return new AgentLoopLlmDispatchStageResult(blocks, dispatchStopwatch.ElapsedMilliseconds); + } + + private sealed class StreamPreviewObserver + { + private readonly Func _formatToolCallSummary; + private readonly Action _emitEvent; + private readonly StringBuilder _streamedTextPreview = new(); + private DateTime _lastStreamUiUpdateAt = DateTime.MinValue; + + public StreamPreviewObserver( + Func formatToolCallSummary, + Action emitEvent) + { + _formatToolCallSummary = formatToolCallSummary; + _emitEvent = emitEvent; + } + + public async Task HandleAsync(ToolStreamEvent evt) + { + switch (evt.Kind) + { + case ToolStreamEventKind.TextDelta: + HandleTextDelta(evt.Text); + break; + case ToolStreamEventKind.ToolCallReady: + if (evt.ToolCall != null) + { + _emitEvent( + AgentEventType.Thinking, + evt.ToolCall.ToolName, + $"Streaming tool ready: {_formatToolCallSummary(evt.ToolCall)}"); + } + break; + case ToolStreamEventKind.RetryReset: + _streamedTextPreview.Clear(); + _lastStreamUiUpdateAt = DateTime.UtcNow; + _emitEvent(AgentEventType.Thinking, "", "Resetting the streamed partial response before retry."); + break; + case ToolStreamEventKind.Completed: + await Task.CompletedTask.ConfigureAwait(false); + break; + } + } + + private void HandleTextDelta(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return; + + _streamedTextPreview.Append(text); + var now = DateTime.UtcNow; + if ((now - _lastStreamUiUpdateAt) < StreamPreviewUiInterval || _streamedTextPreview.Length == 0) + return; + + var preview = AgentProgressSummarySanitizer.NormalizeThinkingSummary( + _streamedTextPreview.ToString(), + maxLength: 140); + if (string.IsNullOrWhiteSpace(preview)) + return; + + _emitEvent(AgentEventType.Thinking, "", preview); + _lastStreamUiUpdateAt = now; + } + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopPreLlmStageService.cs b/src/AxCopilot/Services/Agent/AgentLoopPreLlmStageService.cs index e2ac9ec..0dbbb10 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopPreLlmStageService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopPreLlmStageService.cs @@ -155,13 +155,13 @@ internal static class AgentLoopPreLlmStageService { return ( "No tools remain available under the active skill runtime policy.", - "⚠ No tools are currently allowed by the active skill policy, so the task cannot continue. Check the allowed-tools configuration."); + "No tools are currently allowed by the active skill policy, so the task cannot continue. Check the allowed-tools configuration."); } var tabLabel = string.IsNullOrWhiteSpace(activeTab) ? "current" : activeTab; return ( $"No tools are available in the {tabLabel} tab.", - $"⚠ No tools are available in the {tabLabel} tab, so the task cannot continue. Check the tab-specific tool exposure policy."); + $"No tools are available in the {tabLabel} tab, so the task cannot continue. Check the tab-specific tool exposure policy."); } private static string Truncate(string text, int maxLength) diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index b1786b4..a07a617 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -27,6 +27,7 @@ public partial class AgentLoopService private readonly ToolRegistry _tools; private readonly SettingsService _settings; private readonly IToolExecutionCoordinator _toolExecutionCoordinator; + private readonly AgentLoopLlmDispatchStageService _llmDispatchStageService; // P4: JsonSerializer 옵션 공유 — 익명 객체 직렬화 시 기본 옵션 재생성 방지 private static readonly JsonSerializerOptions s_jsonOpts = new() @@ -161,6 +162,10 @@ public partial class AgentLoopService ForceContextRecovery, IsTransientLlmError, ComputeTransientLlmBackoffDelayMs); + _llmDispatchStageService = new AgentLoopLlmDispatchStageService( + _toolExecutionCoordinator, + FormatToolCallSummary, + (eventType, toolName, summary) => EmitEvent(eventType, toolName, summary)); } public bool TryGetPendingPermissionPreview(string toolName, string target, out PermissionPromptPreview? preview) @@ -543,7 +548,8 @@ public partial class AgentLoopService // LLM에 도구 정의와 함께 요청 List blocks; - var llmCallSw = Stopwatch.StartNew(); + var llmRequest = preLlmStage.LlmRequest; + long llmCallElapsedMs = 0; try { if (!string.IsNullOrWhiteSpace(preLlmStage.MissingToolsReturnMessage)) @@ -551,88 +557,34 @@ public partial class AgentLoopService EmitEvent(AgentEventType.Error, "", preLlmStage.MissingToolsEventSummary ?? "사용 가능한 도구가 없습니다."); return preLlmStage.MissingToolsReturnMessage; } - var activeTools = cachedActiveTools; - var llmRequest = preLlmStage.LlmRequest!; - var forceFirst = llmRequest.ForceInitialToolCall; - var sendMessages = llmRequest.SendMessages; - - // 워크플로우 상세 로그: LLM 요청 - llmCallSw.Restart(); var (_, currentModel) = _llm.GetCurrentModelInfo(); - WorkflowLogService.SetCallContext(_conversationId, _currentRunId, iteration); - WorkflowLogService.LogTransition( - _conversationId, - _currentRunId, - iteration, - "query_context", - BuildQueryContextDetail(queryView, llmRequest, codeWorkingSet)); - WorkflowLogService.LogLlmRequest(_conversationId, _currentRunId, iteration, - currentModel, sendMessages.Count, activeTools.Count, forceFirst); - var streamedTextPreview = new StringBuilder(); - var lastStreamUiUpdateAt = DateTime.MinValue; - - blocks = await SendWithToolsWithRecoveryAsync( - sendMessages, - activeTools, - ct, - $"메인 루프 {iteration}", - runState, - forceToolCall: forceFirst, - prefetchToolCallAsync: block => TryPrefetchReadOnlyToolAsync( - block, - activeTools, + var queryContextDetail = BuildQueryContextDetail(queryView, llmRequest!, codeWorkingSet); + var dispatchResult = await _llmDispatchStageService.ExecuteAsync( + new AgentLoopLlmDispatchStageInput( + iteration, + _conversationId, + _currentRunId, + $"메인 루프 {iteration}", + currentModel, + queryContextDetail, + llmRequest!, + cachedActiveTools, context, - ct), - onStreamEventAsync: async evt => - { - switch (evt.Kind) - { - case ToolStreamEventKind.TextDelta: - if (!string.IsNullOrWhiteSpace(evt.Text)) - { - streamedTextPreview.Append(evt.Text); - var now = DateTime.UtcNow; - if ((now - lastStreamUiUpdateAt).TotalMilliseconds >= 450 && streamedTextPreview.Length > 0) - { - var preview = AgentProgressSummarySanitizer.NormalizeThinkingSummary( - streamedTextPreview.ToString(), - maxLength: 140); - if (!string.IsNullOrWhiteSpace(preview)) - { - EmitEvent(AgentEventType.Thinking, "", preview); - lastStreamUiUpdateAt = now; - } - } - } - break; - case ToolStreamEventKind.ToolCallReady: - if (evt.ToolCall != null) - { - EmitEvent( - AgentEventType.Thinking, - evt.ToolCall.ToolName, - $"스트리밍 도구 감지: {FormatToolCallSummary(evt.ToolCall)}"); - } - break; - case ToolStreamEventKind.Completed: - await Task.CompletedTask; - break; - case ToolStreamEventKind.RetryReset: - streamedTextPreview.Clear(); - lastStreamUiUpdateAt = DateTime.UtcNow; - EmitEvent(AgentEventType.Thinking, "", "스트리밍 중간 응답을 정리하고 재시도합니다."); - break; - } - }); - runState.ContextRecoveryAttempts = 0; - llmCallSw.Stop(); - runState.TransientLlmErrorRetries = 0; + runState), + ct).ConfigureAwait(false); + blocks = dispatchResult.Blocks; + llmCallElapsedMs = dispatchResult.ElapsedMilliseconds; NotifyPostCompactionTurnIfNeeded(runState); } catch (NotSupportedException) { // Function Calling 미지원 서비스 → 일반 텍스트 응답으로 대체 - var textResp = await _llm.SendAsync(queryMessages, ct); + var textFallbackMessages = llmRequest == null + ? queryMessages + : llmRequest.InjectedToolReminder && llmRequest.SendMessages.Count > 0 + ? llmRequest.SendMessages.Take(llmRequest.SendMessages.Count - 1).ToList() + : llmRequest.SendMessages; + var textResp = await _llm.SendAsync(textFallbackMessages, ct); EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료"); return textResp; } @@ -754,7 +706,7 @@ public partial class AgentLoopService textResponse, toolCalls.Count, _llm.LastTokenUsage?.PromptTokens ?? 0, _llm.LastTokenUsage?.CompletionTokens ?? 0, - llmCallSw.ElapsedMilliseconds); + llmCallElapsedMs); // Task Decomposition: 첫 번째 텍스트 응답에서 계획 단계 추출 if (!planExtracted && !string.IsNullOrEmpty(textResponse)) diff --git a/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs b/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs index d12463c..693c598 100644 --- a/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs +++ b/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs @@ -68,13 +68,13 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina _emitEvent( AgentEventType.Thinking, resolvedToolName, - $"읽기 도구 조기 실행 준비: {resolvedToolName}"); + $"Preparing early execution for {resolvedToolName}"); var sw = Stopwatch.StartNew(); try { var input = block.ToolInput ?? JsonDocument.Parse("{}").RootElement; - var result = await _executeToolAsync(resolvedToolName, input, context, null, ct); + var result = await _executeToolAsync(resolvedToolName, input, context, null, ct).ConfigureAwait(false); sw.Stop(); return new ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName); } @@ -82,7 +82,7 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina { sw.Stop(); return new ToolPrefetchResult( - ToolResult.Fail($"조기 실행 오류: {ex.Message}"), + ToolResult.Fail($"Early execution failed: {ex.Message}"), sw.ElapsedMilliseconds, resolvedToolName); } @@ -107,14 +107,14 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina try { if (onStreamEventAsync == null) - return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync); + return await _llm.SendWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync).ConfigureAwait(false); var blocks = new List(); var textBuilder = new StringBuilder(); var (service, model) = _llm.GetCurrentModelInfo(); LogService.Info( - $"[AgentLoopWait] {phaseLabel}: LLM 요청 시작 (service={service}, model={model}, messages={messages.Count}, tools={tools.Count}, forceToolCall={forceToolCall})"); - _emitEvent(AgentEventType.Thinking, "", $"{phaseLabel}: 모델에 요청하는 중입니다..."); + $"[AgentLoopWait] {phaseLabel}: starting LLM request (service={service}, model={model}, messages={messages.Count}, tools={tools.Count}, forceToolCall={forceToolCall})"); + _emitEvent(AgentEventType.Thinking, "", $"{phaseLabel}: requesting the model..."); var waitStopwatch = Stopwatch.StartNew(); var firstEventReceived = false; @@ -151,12 +151,12 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina { firstEventReceived = true; LogService.Debug( - $"[AgentLoopWait] {phaseLabel}: 첫 응답 수신 ({waitStopwatch.ElapsedMilliseconds}ms, kind={evt.Kind})"); + $"[AgentLoopWait] {phaseLabel}: first response received ({waitStopwatch.ElapsedMilliseconds}ms, kind={evt.Kind})"); if (waitStopwatch.Elapsed >= _firstResponseHeartbeatDelay) - _emitEvent(AgentEventType.Thinking, "", $"{phaseLabel}: 모델 첫 응답을 받아 계속 진행합니다."); + _emitEvent(AgentEventType.Thinking, "", $"{phaseLabel}: received the first response and continuing."); } - await onStreamEventAsync(evt); + await onStreamEventAsync(evt).ConfigureAwait(false); if (evt.Kind == ToolStreamEventKind.TextDelta && !string.IsNullOrWhiteSpace(evt.Text)) { streamedAnyPartialState = true; @@ -183,7 +183,11 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina && _forceContextRecovery(messages)) { if (onStreamEventAsync != null && streamedAnyPartialState) - await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry")); + { + await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry")) + .ConfigureAwait(false); + } + contextRecoveryRetries++; if (runState != null) runState.ContextRecoveryAttempts = contextRecoveryRetries; @@ -191,7 +195,7 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina _emitEvent( AgentEventType.Thinking, "", - $"{phaseLabel}: 컨텍스트 한도 초과로 대화를 압축한 후 재시도합니다 ({contextRecoveryRetries}/2)"); + $"{phaseLabel}: context overflow detected. Recovering and retrying ({contextRecoveryRetries}/2)"); continue; } @@ -201,7 +205,11 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina if (_isTransientLlmError(ex) && transientRetries < 3) { if (onStreamEventAsync != null && streamedAnyPartialState) - await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry")); + { + await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry")) + .ConfigureAwait(false); + } + transientRetries++; if (runState != null) runState.TransientLlmErrorRetries = transientRetries; @@ -210,8 +218,8 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina _emitEvent( AgentEventType.Thinking, "", - $"{phaseLabel}: 일시적 LLM 오류로 {delayMs}ms 후 재시도합니다 ({transientRetries}/3)"); - await Task.Delay(delayMs, ct); + $"{phaseLabel}: transient LLM error. Retrying after {delayMs}ms ({transientRetries}/3)"); + await Task.Delay(delayMs, ct).ConfigureAwait(false); continue; } @@ -224,8 +232,8 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina { var seconds = Math.Max(1, (int)Math.Round(waited.TotalSeconds)); var summary = firstEventReceived - ? $"{phaseLabel}: 모델 응답이 길어져 계속 기다리는 중입니다... ({seconds}초)" - : $"{phaseLabel}: 모델 첫 응답을 기다리는 중입니다... ({seconds}초)"; + ? $"{phaseLabel}: model response is slow; still waiting... ({seconds}s)" + : $"{phaseLabel}: waiting for the first model response... ({seconds}s)"; LogService.Debug($"[AgentLoopWait] {summary}"); _emitEvent(AgentEventType.Thinking, "", summary); }