코드탭 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:
18
README.md
18
README.md
@@ -1,5 +1,23 @@
|
||||
# AX Commander
|
||||
|
||||
- Update: 2026-04-16 06:41 (KST)
|
||||
- Continued the Code-tab structural refactor by extracting the dispatch/stream stage into [AgentLoopLlmDispatchStageService.cs](</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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.CreateDefault();
|
||||
|
||||
private static string BuildQueryContextDetail(
|
||||
internal static string BuildQueryContextDetail(
|
||||
AgentQueryContextWindowResult queryView,
|
||||
AgentLoopLlmRequestPreparationResult llmRequest,
|
||||
CodeTaskWorkingSetService? codeWorkingSet)
|
||||
|
||||
152
src/AxCopilot/Services/Agent/AgentLoopLlmDispatchStageService.cs
Normal file
152
src/AxCopilot/Services/Agent/AgentLoopLlmDispatchStageService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user