코드탭 LLM 디스패치 단계를 별도 서비스로 분리

AgentLoopLlmDispatchStageService를 추가해 query_context와 llm_request 로그, 스트리밍 미리보기, read-only prefetch 연결, recovery-capable tool dispatch를 한 단계로 분리했다.

AgentLoopService는 pre-LLM stage 이후 dispatch stage를 호출하는 오케스트레이터 형태로 정리했고 function-calling 미지원 fallback도 staged request를 재사용하도록 보정했다.

StreamingToolExecutionCoordinator와 AgentLoopPreLlmStageService의 깨진 문자열을 영어로 정리하고 AgentLoopLlmDispatchStageServiceTests를 추가했다.

검증: Release build 경고 0 오류 0, 관련 AgentLoop 회귀 테스트 43개 통과
This commit is contained in:
2026-04-16 06:51:00 +09:00
parent 1ec529ed1c
commit c6e5abfa50
9 changed files with 445 additions and 96 deletions
+18
View File
@@ -1,5 +1,23 @@
# AX Commander # AX Commander
- Update: 2026-04-16 06:41 (KST)
- Continued the Code-tab structural refactor by extracting the dispatch/stream stage into [AgentLoopLlmDispatchStageService.cs](</E:/AX Copilot - Codex/src/AxCopilot/Services/Agent/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](</E:/AX Copilot - Codex/src/AxCopilot/Services/Agent/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](</E:/AX Copilot - Codex/src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs>) was also rewritten with English-only status strings so the active stream/wait path no longer carries mojibake text, and [AgentLoopPreLlmStageService.cs](</E:/AX Copilot - Codex/src/AxCopilot/Services/Agent/AgentLoopPreLlmStageService.cs>) now returns clean English missing-tool failures.
- Added [AgentLoopLlmDispatchStageServiceTests.cs](</E:/AX Copilot - Codex/src/AxCopilot.Tests/Services/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) - Update: 2026-04-16 02:13 (KST)
- Continued the Code-tab structural refactor by extracting the pre-LLM stage into [AgentLoopPreLlmStageService.cs](</E:/AX Copilot - Codex/src/AxCopilot/Services/Agent/AgentLoopPreLlmStageService.cs>). - Continued the Code-tab structural refactor by extracting the pre-LLM stage into [AgentLoopPreLlmStageService.cs](</E:/AX Copilot - Codex/src/AxCopilot/Services/Agent/AgentLoopPreLlmStageService.cs>).
- The new stage service now owns: - The new stage service now owns:
+13
View File
@@ -1,5 +1,18 @@
# Code Context Reliability Plan # 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) Update: 2026-04-16 01:37 (KST)
## Background ## Background
+8
View File
@@ -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 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 - `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
@@ -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<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
=> Task.FromResult(ToolResult.Ok("ok"));
}
private sealed class FakeToolExecutionCoordinator : IToolExecutionCoordinator
{
public List<ChatMessage>? CapturedMessages { get; private set; }
public IReadOnlyCollection<IAgentTool>? 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<ToolStreamEvent, Task>?, Func<ContentBlock, Task<ToolPrefetchResult?>>?, Task<List<ContentBlock>>>? DispatchAsync { get; init; }
public Task<ToolPrefetchResult?> TryPrefetchReadOnlyToolAsync(
ContentBlock block,
IReadOnlyCollection<IAgentTool> tools,
AgentContext context,
CancellationToken ct)
{
PrefetchCallCount++;
PrefetchContext = context;
return Task.FromResult<ToolPrefetchResult?>(
new ToolPrefetchResult(ToolResult.Ok("prefetched"), 5, block.ToolName));
}
public async Task<List<ContentBlock>> SendWithToolsWithRecoveryAsync(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
CancellationToken ct,
string phaseLabel,
AgentLoopService.RunState? runState = null,
bool forceToolCall = false,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null,
Func<ToolStreamEvent, Task>? 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<ContentBlock>
{
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."));
}
}
@@ -7,7 +7,7 @@ public partial class AgentLoopService
? AgentQueryContextBuilder.AgentQueryContextBuildOptions.CreateCodeDefault() ? AgentQueryContextBuilder.AgentQueryContextBuildOptions.CreateCodeDefault()
: AgentQueryContextBuilder.AgentQueryContextBuildOptions.CreateDefault(); : AgentQueryContextBuilder.AgentQueryContextBuildOptions.CreateDefault();
private static string BuildQueryContextDetail( internal static string BuildQueryContextDetail(
AgentQueryContextWindowResult queryView, AgentQueryContextWindowResult queryView,
AgentLoopLlmRequestPreparationResult llmRequest, AgentLoopLlmRequestPreparationResult llmRequest,
CodeTaskWorkingSetService? codeWorkingSet) CodeTaskWorkingSetService? codeWorkingSet)
@@ -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<IAgentTool> ActiveTools,
AgentContext Context,
AgentLoopService.RunState RunState);
internal sealed record AgentLoopLlmDispatchStageResult(
List<ContentBlock> Blocks,
long ElapsedMilliseconds);
/// <summary>
/// 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.
/// </summary>
internal sealed class AgentLoopLlmDispatchStageService
{
private static readonly TimeSpan StreamPreviewUiInterval = TimeSpan.FromMilliseconds(450);
private readonly IToolExecutionCoordinator _toolExecutionCoordinator;
private readonly Func<ContentBlock, string> _formatToolCallSummary;
private readonly Action<AgentEventType, string, string> _emitEvent;
public AgentLoopLlmDispatchStageService(
IToolExecutionCoordinator toolExecutionCoordinator,
Func<ContentBlock, string> formatToolCallSummary,
Action<AgentEventType, string, string> emitEvent)
{
_toolExecutionCoordinator = toolExecutionCoordinator;
_formatToolCallSummary = formatToolCallSummary;
_emitEvent = emitEvent;
}
public async Task<AgentLoopLlmDispatchStageResult> 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<ContentBlock, string> _formatToolCallSummary;
private readonly Action<AgentEventType, string, string> _emitEvent;
private readonly StringBuilder _streamedTextPreview = new();
private DateTime _lastStreamUiUpdateAt = DateTime.MinValue;
public StreamPreviewObserver(
Func<ContentBlock, string> formatToolCallSummary,
Action<AgentEventType, string, string> 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;
}
}
}
@@ -155,13 +155,13 @@ internal static class AgentLoopPreLlmStageService
{ {
return ( return (
"No tools remain available under the active skill runtime policy.", "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; var tabLabel = string.IsNullOrWhiteSpace(activeTab) ? "current" : activeTab;
return ( return (
$"No tools are available in the {tabLabel} tab.", $"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) private static string Truncate(string text, int maxLength)
@@ -27,6 +27,7 @@ public partial class AgentLoopService
private readonly ToolRegistry _tools; private readonly ToolRegistry _tools;
private readonly SettingsService _settings; private readonly SettingsService _settings;
private readonly IToolExecutionCoordinator _toolExecutionCoordinator; private readonly IToolExecutionCoordinator _toolExecutionCoordinator;
private readonly AgentLoopLlmDispatchStageService _llmDispatchStageService;
// P4: JsonSerializer 옵션 공유 — 익명 객체 직렬화 시 기본 옵션 재생성 방지 // P4: JsonSerializer 옵션 공유 — 익명 객체 직렬화 시 기본 옵션 재생성 방지
private static readonly JsonSerializerOptions s_jsonOpts = new() private static readonly JsonSerializerOptions s_jsonOpts = new()
@@ -161,6 +162,10 @@ public partial class AgentLoopService
ForceContextRecovery, ForceContextRecovery,
IsTransientLlmError, IsTransientLlmError,
ComputeTransientLlmBackoffDelayMs); ComputeTransientLlmBackoffDelayMs);
_llmDispatchStageService = new AgentLoopLlmDispatchStageService(
_toolExecutionCoordinator,
FormatToolCallSummary,
(eventType, toolName, summary) => EmitEvent(eventType, toolName, summary));
} }
public bool TryGetPendingPermissionPreview(string toolName, string target, out PermissionPromptPreview? preview) public bool TryGetPendingPermissionPreview(string toolName, string target, out PermissionPromptPreview? preview)
@@ -543,7 +548,8 @@ public partial class AgentLoopService
// LLM에 도구 정의와 함께 요청 // LLM에 도구 정의와 함께 요청
List<ContentBlock> blocks; List<ContentBlock> blocks;
var llmCallSw = Stopwatch.StartNew(); var llmRequest = preLlmStage.LlmRequest;
long llmCallElapsedMs = 0;
try try
{ {
if (!string.IsNullOrWhiteSpace(preLlmStage.MissingToolsReturnMessage)) if (!string.IsNullOrWhiteSpace(preLlmStage.MissingToolsReturnMessage))
@@ -551,88 +557,34 @@ public partial class AgentLoopService
EmitEvent(AgentEventType.Error, "", preLlmStage.MissingToolsEventSummary ?? "사용 가능한 도구가 없습니다."); EmitEvent(AgentEventType.Error, "", preLlmStage.MissingToolsEventSummary ?? "사용 가능한 도구가 없습니다.");
return preLlmStage.MissingToolsReturnMessage; 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(); var (_, currentModel) = _llm.GetCurrentModelInfo();
WorkflowLogService.SetCallContext(_conversationId, _currentRunId, iteration); var queryContextDetail = BuildQueryContextDetail(queryView, llmRequest!, codeWorkingSet);
WorkflowLogService.LogTransition( var dispatchResult = await _llmDispatchStageService.ExecuteAsync(
_conversationId, new AgentLoopLlmDispatchStageInput(
_currentRunId, iteration,
iteration, _conversationId,
"query_context", _currentRunId,
BuildQueryContextDetail(queryView, llmRequest, codeWorkingSet)); $"메인 루프 {iteration}",
WorkflowLogService.LogLlmRequest(_conversationId, _currentRunId, iteration, currentModel,
currentModel, sendMessages.Count, activeTools.Count, forceFirst); queryContextDetail,
var streamedTextPreview = new StringBuilder(); llmRequest!,
var lastStreamUiUpdateAt = DateTime.MinValue; cachedActiveTools,
blocks = await SendWithToolsWithRecoveryAsync(
sendMessages,
activeTools,
ct,
$"메인 루프 {iteration}",
runState,
forceToolCall: forceFirst,
prefetchToolCallAsync: block => TryPrefetchReadOnlyToolAsync(
block,
activeTools,
context, context,
ct), runState),
onStreamEventAsync: async evt => ct).ConfigureAwait(false);
{ blocks = dispatchResult.Blocks;
switch (evt.Kind) llmCallElapsedMs = dispatchResult.ElapsedMilliseconds;
{
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;
NotifyPostCompactionTurnIfNeeded(runState); NotifyPostCompactionTurnIfNeeded(runState);
} }
catch (NotSupportedException) catch (NotSupportedException)
{ {
// Function Calling 미지원 서비스 → 일반 텍스트 응답으로 대체 // 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, "", "에이전트 작업 완료"); EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
return textResp; return textResp;
} }
@@ -754,7 +706,7 @@ public partial class AgentLoopService
textResponse, toolCalls.Count, textResponse, toolCalls.Count,
_llm.LastTokenUsage?.PromptTokens ?? 0, _llm.LastTokenUsage?.PromptTokens ?? 0,
_llm.LastTokenUsage?.CompletionTokens ?? 0, _llm.LastTokenUsage?.CompletionTokens ?? 0,
llmCallSw.ElapsedMilliseconds); llmCallElapsedMs);
// Task Decomposition: 첫 번째 텍스트 응답에서 계획 단계 추출 // Task Decomposition: 첫 번째 텍스트 응답에서 계획 단계 추출
if (!planExtracted && !string.IsNullOrEmpty(textResponse)) if (!planExtracted && !string.IsNullOrEmpty(textResponse))
@@ -68,13 +68,13 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
_emitEvent( _emitEvent(
AgentEventType.Thinking, AgentEventType.Thinking,
resolvedToolName, resolvedToolName,
$"읽기 도구 조기 실행 준비: {resolvedToolName}"); $"Preparing early execution for {resolvedToolName}");
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
try try
{ {
var input = block.ToolInput ?? JsonDocument.Parse("{}").RootElement; 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(); sw.Stop();
return new ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName); return new ToolPrefetchResult(result, sw.ElapsedMilliseconds, resolvedToolName);
} }
@@ -82,7 +82,7 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
{ {
sw.Stop(); sw.Stop();
return new ToolPrefetchResult( return new ToolPrefetchResult(
ToolResult.Fail($"조기 실행 오류: {ex.Message}"), ToolResult.Fail($"Early execution failed: {ex.Message}"),
sw.ElapsedMilliseconds, sw.ElapsedMilliseconds,
resolvedToolName); resolvedToolName);
} }
@@ -107,14 +107,14 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
try try
{ {
if (onStreamEventAsync == null) 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<ContentBlock>(); var blocks = new List<ContentBlock>();
var textBuilder = new StringBuilder(); var textBuilder = new StringBuilder();
var (service, model) = _llm.GetCurrentModelInfo(); var (service, model) = _llm.GetCurrentModelInfo();
LogService.Info( LogService.Info(
$"[AgentLoopWait] {phaseLabel}: LLM 요청 시작 (service={service}, model={model}, messages={messages.Count}, tools={tools.Count}, forceToolCall={forceToolCall})"); $"[AgentLoopWait] {phaseLabel}: starting LLM request (service={service}, model={model}, messages={messages.Count}, tools={tools.Count}, forceToolCall={forceToolCall})");
_emitEvent(AgentEventType.Thinking, "", $"{phaseLabel}: 모델에 요청하는 중입니다..."); _emitEvent(AgentEventType.Thinking, "", $"{phaseLabel}: requesting the model...");
var waitStopwatch = Stopwatch.StartNew(); var waitStopwatch = Stopwatch.StartNew();
var firstEventReceived = false; var firstEventReceived = false;
@@ -151,12 +151,12 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
{ {
firstEventReceived = true; firstEventReceived = true;
LogService.Debug( 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) 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)) if (evt.Kind == ToolStreamEventKind.TextDelta && !string.IsNullOrWhiteSpace(evt.Text))
{ {
streamedAnyPartialState = true; streamedAnyPartialState = true;
@@ -183,7 +183,11 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
&& _forceContextRecovery(messages)) && _forceContextRecovery(messages))
{ {
if (onStreamEventAsync != null && streamedAnyPartialState) if (onStreamEventAsync != null && streamedAnyPartialState)
await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry")); {
await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry"))
.ConfigureAwait(false);
}
contextRecoveryRetries++; contextRecoveryRetries++;
if (runState != null) if (runState != null)
runState.ContextRecoveryAttempts = contextRecoveryRetries; runState.ContextRecoveryAttempts = contextRecoveryRetries;
@@ -191,7 +195,7 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
_emitEvent( _emitEvent(
AgentEventType.Thinking, AgentEventType.Thinking,
"", "",
$"{phaseLabel}: 컨텍스트 한도 초과로 대화를 압축한 후 재시도합니다 ({contextRecoveryRetries}/2)"); $"{phaseLabel}: context overflow detected. Recovering and retrying ({contextRecoveryRetries}/2)");
continue; continue;
} }
@@ -201,7 +205,11 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
if (_isTransientLlmError(ex) && transientRetries < 3) if (_isTransientLlmError(ex) && transientRetries < 3)
{ {
if (onStreamEventAsync != null && streamedAnyPartialState) if (onStreamEventAsync != null && streamedAnyPartialState)
await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry")); {
await onStreamEventAsync(new ToolStreamEvent(ToolStreamEventKind.RetryReset, $"{phaseLabel}:retry"))
.ConfigureAwait(false);
}
transientRetries++; transientRetries++;
if (runState != null) if (runState != null)
runState.TransientLlmErrorRetries = transientRetries; runState.TransientLlmErrorRetries = transientRetries;
@@ -210,8 +218,8 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
_emitEvent( _emitEvent(
AgentEventType.Thinking, AgentEventType.Thinking,
"", "",
$"{phaseLabel}: 일시적 LLM 오류로 {delayMs}ms 후 재시도합니다 ({transientRetries}/3)"); $"{phaseLabel}: transient LLM error. Retrying after {delayMs}ms ({transientRetries}/3)");
await Task.Delay(delayMs, ct); await Task.Delay(delayMs, ct).ConfigureAwait(false);
continue; continue;
} }
@@ -224,8 +232,8 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
{ {
var seconds = Math.Max(1, (int)Math.Round(waited.TotalSeconds)); var seconds = Math.Max(1, (int)Math.Round(waited.TotalSeconds));
var summary = firstEventReceived var summary = firstEventReceived
? $"{phaseLabel}: 모델 응답이 길어져 계속 기다리는 중입니다... ({seconds}초)" ? $"{phaseLabel}: model response is slow; still waiting... ({seconds}s)"
: $"{phaseLabel}: 모델 첫 응답을 기다리는 중입니다... ({seconds}초)"; : $"{phaseLabel}: waiting for the first model response... ({seconds}s)";
LogService.Debug($"[AgentLoopWait] {summary}"); LogService.Debug($"[AgentLoopWait] {summary}");
_emitEvent(AgentEventType.Thinking, "", summary); _emitEvent(AgentEventType.Thinking, "", summary);
} }