코드탭 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

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:

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

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

View File

@@ -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."));
}
}

View File

@@ -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)

View File

@@ -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;
}
}
}

View File

@@ -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)

View File

@@ -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))

View File

@@ -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);
} }