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

View File

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

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 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.CreateDefault();
private static string BuildQueryContextDetail(
internal static string BuildQueryContextDetail(
AgentQueryContextWindowResult queryView,
AgentLoopLlmRequestPreparationResult llmRequest,
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 (
"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)

View File

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

View File

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