From 1ec529ed1c6d5d956f5f40a71deea38e5760107c Mon Sep 17 00:00:00 2001 From: lacvet Date: Thu, 16 Apr 2026 02:18:05 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BD=94=EB=93=9C=ED=83=AD=20pre-LLM=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=EA=B2=B0=EC=A0=95=EC=9D=84=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AgentLoopPreLlmStageService를 추가해 thinking summary, Gemini free-tier delay, user prompt submit hook, missing-tool guard, request assembly handoff를 한 단계로 정리함 - AgentLoopService는 pre-LLM stage 결과를 소비하는 형태로 단순화해 history/query assembly 다음 단계가 claw-code처럼 더 선명하게 보이도록 구조를 개선함 - AgentLoopPreLlmStageServiceTests를 추가하고 관련 구조 테스트 60개를 통과시켰으며 dotnet build 경고/오류 0으로 검증함 --- README.md | 18 ++ docs/CODE_CONTEXT_RELIABILITY_PLAN.md | 18 ++ docs/DEVELOPMENT.md | 23 +++ .../AgentLoopPreLlmStageServiceTests.cs | 167 +++++++++++++++++ .../Agent/AgentLoopPreLlmStageService.cs | 174 ++++++++++++++++++ .../Services/Agent/AgentLoopService.cs | 99 +++++----- 6 files changed, 443 insertions(+), 56 deletions(-) create mode 100644 src/AxCopilot.Tests/Services/AgentLoopPreLlmStageServiceTests.cs create mode 100644 src/AxCopilot/Services/Agent/AgentLoopPreLlmStageService.cs diff --git a/README.md b/README.md index 27ea0d7..bb23f78 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,23 @@ # AX Commander +- Update: 2026-04-16 02:13 (KST) +- Continued the Code-tab structural refactor by extracting the pre-LLM stage into [AgentLoopPreLlmStageService.cs](). +- The new stage service now owns: + - iteration thinking-summary selection + - Gemini free-tier delay planning + - user-prompt submit hook planning and payload generation + - missing-tool guard construction + - final request assembly handoff through the staged query/history pipeline +- [AgentLoopService.cs]() now reads more like an orchestrator: history/query assembly, pre-LLM stage planning, LLM dispatch, tool execution, and recovery. +- Added [AgentLoopPreLlmStageServiceTests.cs]() to lock: + - free-tier delay planning + - prompt-submit hook dedup by fingerprint + - runtime-policy missing-tool failure shaping + - working-set-backed request assembly +- Validation: + - `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\\` warnings 0 / errors 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\\` passed 60 + - Update: 2026-04-16 02:05 (KST) - Continued the Code-tab context reliability work with a structural refactor that moves query/history assembly closer to the staged shape used by `claw-code`. - Added `src/AxCopilot/Services/Agent/AgentLoopQueryAssemblyService.cs` so `AgentLoopService` no longer directly owns all of the following responsibilities in one place: diff --git a/docs/CODE_CONTEXT_RELIABILITY_PLAN.md b/docs/CODE_CONTEXT_RELIABILITY_PLAN.md index 082f6f6..fe9ecc8 100644 --- a/docs/CODE_CONTEXT_RELIABILITY_PLAN.md +++ b/docs/CODE_CONTEXT_RELIABILITY_PLAN.md @@ -303,3 +303,21 @@ Updated: 2026-04-16 02:05 (KST) - Remaining follow-up: - keep extracting more inline AgentLoop responsibilities into smaller staged services where it improves observability or retry correctness - continue measuring long Code runs against claw-code-style continuity scenarios + +Updated: 2026-04-16 02:13 (KST) + +- Delivered in this pass: + - structural alignment step: + - `AgentLoopPreLlmStageService.cs` now owns the iteration decisions immediately before the LLM call. + - the service centralizes: + - thinking-summary selection + - Gemini free-tier delay planning + - user-prompt submit hook fingerprint/payload planning + - missing-tool guard shaping + - request assembly handoff + - `AgentLoopService.cs` now consumes that stage result instead of computing those branches inline. + - test coverage step: + - `AgentLoopPreLlmStageServiceTests.cs` now locks the new pre-LLM decision layer. +- Remaining follow-up: + - continue extracting the actual LLM dispatch / streaming callback branch into a narrower execution service + - compare long-running Code traces against claw-code-style staged transitions and keep reducing inline loop logic diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 863daaf..c9a1a7a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1850,3 +1850,26 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫 - 검증: - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_query_assembly_structure\\ -p:IntermediateOutputPath=obj\\verify_query_assembly_structure\\` 경고 0 / 오류 0 - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopQueryAssemblyServiceTests|AgentLoopIterationPreparationServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentMessageInvariantHelperTests|SessionLearningCollectorTests|CodeTaskWorkingSetServiceTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_query_assembly_structure_tests\\ -p:IntermediateOutputPath=obj\\verify_query_assembly_structure_tests\\` 통과 56 + +업데이트: 2026-04-16 02:13 (KST) +- Code 탭 pre-LLM stage 구조를 단계형 서비스로 추가 분리했습니다. + - `src/AxCopilot/Services/Agent/AgentLoopPreLlmStageService.cs` + - 반복당 LLM 호출 직전 결정을 한 곳으로 모았습니다. + - thinking summary 선택 + - Gemini free-tier delay 계획 + - user prompt submit hook fingerprint/payload 생성 + - missing-tool guard 문구 생성 + - final request assembly handoff + 를 담당합니다. + - `src/AxCopilot/Services/Agent/AgentLoopService.cs` + - 위 결정들을 직접 계산하던 인라인 코드를 제거하고, pre-LLM stage 결과를 해석해 이벤트/대기/훅 실행만 수행하도록 단순화했습니다. + - 결과적으로 AgentLoop는 `history/query assembly -> pre-LLM stage -> dispatch -> tool execution/recovery` 흐름으로 읽히게 됐습니다. + - `src/AxCopilot.Tests/Services/AgentLoopPreLlmStageServiceTests.cs` + - free-tier delay 계획 + - prompt hook fingerprint dedup + - runtime policy missing-tool failure + - working set supplemental request assembly + 를 회귀 테스트로 고정했습니다. +- 검증: + - `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 diff --git a/src/AxCopilot.Tests/Services/AgentLoopPreLlmStageServiceTests.cs b/src/AxCopilot.Tests/Services/AgentLoopPreLlmStageServiceTests.cs new file mode 100644 index 0000000..326e252 --- /dev/null +++ b/src/AxCopilot.Tests/Services/AgentLoopPreLlmStageServiceTests.cs @@ -0,0 +1,167 @@ +using System.Text.Json; +using AxCopilot.Models; +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class AgentLoopPreLlmStageServiceTests +{ + private sealed class FakeAgentTool : IAgentTool + { + public string Name { get; init; } = "file_read"; + public string Description { get; init; } = "Reads a file"; + public ToolParameterSchema Parameters { get; init; } = new(); + + public Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default) + => Task.FromResult(ToolResult.Ok("ok")); + } + + [Fact] + public void Prepare_ShouldPlanFreeTierDelayForGeminiAfterFirstIteration() + { + var result = AgentLoopPreLlmStageService.Prepare(new AgentLoopPreLlmStageInput( + QueryMessages: [new ChatMessage { Role = "user", Content = "inspect" }], + ActiveTools: [new FakeAgentTool()], + CodeWorkingSet: null, + ActiveTab: "Code", + AgentLogLevel: "info", + Iteration: 2, + MaxIterations: 40, + FreeTierMode: true, + ActiveService: "gemini", + FreeTierDelaySeconds: 5, + LatestUserPrompt: "inspect", + LastUserPromptHookFingerprint: null, + RunId: "run-1", + TotalToolCalls: 1, + ForceInitialToolCallEnabled: true, + InjectPreCallToolReminder: true, + NoToolCallLoopRetry: 0, + HasRuntimeOverride: false, + RuntimeAllowedToolCount: 0)); + + result.FreeTierDelayMilliseconds.Should().Be(5000); + result.FreeTierDelaySummary.Should().Contain("5s"); + } + + [Fact] + public void Prepare_ShouldEmitUserPromptHookOnlyWhenFingerprintChanges() + { + var unchanged = AgentLoopPreLlmStageService.Prepare(new AgentLoopPreLlmStageInput( + QueryMessages: [new ChatMessage { Role = "user", Content = "fix build" }], + ActiveTools: [new FakeAgentTool()], + CodeWorkingSet: null, + ActiveTab: "Code", + AgentLogLevel: "debug", + Iteration: 1, + MaxIterations: 20, + FreeTierMode: false, + ActiveService: "vllm", + FreeTierDelaySeconds: 0, + LatestUserPrompt: "fix build", + LastUserPromptHookFingerprint: $"{ "fix build".Length}:{ "fix build".GetHashCode()}", + RunId: "run-2", + TotalToolCalls: 0, + ForceInitialToolCallEnabled: true, + InjectPreCallToolReminder: true, + NoToolCallLoopRetry: 0, + HasRuntimeOverride: false, + RuntimeAllowedToolCount: 0)); + + unchanged.ShouldRunUserPromptSubmitHook.Should().BeFalse(); + unchanged.UserPromptSubmitHookPayloadJson.Should().BeNull(); + + var changed = AgentLoopPreLlmStageService.Prepare(new AgentLoopPreLlmStageInput( + QueryMessages: [new ChatMessage { Role = "user", Content = "fix build" }], + ActiveTools: [new FakeAgentTool()], + CodeWorkingSet: null, + ActiveTab: "Code", + AgentLogLevel: "debug", + Iteration: 1, + MaxIterations: 20, + FreeTierMode: false, + ActiveService: "vllm", + FreeTierDelaySeconds: 0, + LatestUserPrompt: "fix build", + LastUserPromptHookFingerprint: null, + RunId: "run-3", + TotalToolCalls: 0, + ForceInitialToolCallEnabled: true, + InjectPreCallToolReminder: true, + NoToolCallLoopRetry: 0, + HasRuntimeOverride: false, + RuntimeAllowedToolCount: 0)); + + changed.ShouldRunUserPromptSubmitHook.Should().BeTrue(); + changed.UserPromptSubmitHookPayloadJson.Should().Contain("\"runId\":\"run-3\""); + changed.ThinkingSummary.Should().Contain("iteration 1/20"); + } + + [Fact] + public void Prepare_ShouldReturnRuntimeSpecificMissingToolsFailure() + { + var result = AgentLoopPreLlmStageService.Prepare(new AgentLoopPreLlmStageInput( + QueryMessages: [new ChatMessage { Role = "user", Content = "fix build" }], + ActiveTools: [], + CodeWorkingSet: null, + ActiveTab: "Code", + AgentLogLevel: "info", + Iteration: 1, + MaxIterations: 20, + FreeTierMode: false, + ActiveService: "vllm", + FreeTierDelaySeconds: 0, + LatestUserPrompt: "fix build", + LastUserPromptHookFingerprint: null, + RunId: "run-4", + TotalToolCalls: 0, + ForceInitialToolCallEnabled: true, + InjectPreCallToolReminder: true, + NoToolCallLoopRetry: 0, + HasRuntimeOverride: true, + RuntimeAllowedToolCount: 3)); + + result.LlmRequest.Should().BeNull(); + result.MissingToolsEventSummary.Should().Contain("skill runtime policy"); + result.MissingToolsReturnMessage.Should().Contain("allowed-tools"); + } + + [Fact] + public void Prepare_ShouldBuildLlmRequestWhenToolsExist() + { + var workingSet = new CodeTaskWorkingSetService( + "create WPF shell", + @"E:\code", + "wpf-mvvm", + startedFromEmptyWorkspace: true); + using var mkdirDoc = JsonDocument.Parse("""{"action":"mkdir","paths":["Views","ViewModels"]}"""); + workingSet.RecordToolResult("file_manage", mkdirDoc.RootElement.Clone(), ToolResult.Ok("created directories")); + + var result = AgentLoopPreLlmStageService.Prepare(new AgentLoopPreLlmStageInput( + QueryMessages: [new ChatMessage { Role = "user", Content = "create WPF shell" }], + ActiveTools: [new FakeAgentTool()], + CodeWorkingSet: workingSet, + ActiveTab: "Code", + AgentLogLevel: "info", + Iteration: 1, + MaxIterations: 30, + FreeTierMode: false, + ActiveService: "vllm", + FreeTierDelaySeconds: 0, + LatestUserPrompt: "create WPF shell", + LastUserPromptHookFingerprint: null, + RunId: "run-5", + TotalToolCalls: 0, + ForceInitialToolCallEnabled: true, + InjectPreCallToolReminder: true, + NoToolCallLoopRetry: 0, + HasRuntimeOverride: false, + RuntimeAllowedToolCount: 0)); + + result.LlmRequest.Should().NotBeNull(); + result.LlmRequest!.SupplementalMessageCount.Should().Be(1); + result.LlmRequest.SendMessages.Should().Contain(message => message.MetaKind == "code_working_set"); + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopPreLlmStageService.cs b/src/AxCopilot/Services/Agent/AgentLoopPreLlmStageService.cs new file mode 100644 index 0000000..e2ac9ec --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopPreLlmStageService.cs @@ -0,0 +1,174 @@ +using System.Text.Json; +using AxCopilot.Models; + +namespace AxCopilot.Services.Agent; + +internal sealed record AgentLoopPreLlmStageInput( + IReadOnlyList QueryMessages, + IReadOnlyCollection ActiveTools, + CodeTaskWorkingSetService? CodeWorkingSet, + string? ActiveTab, + string? AgentLogLevel, + int Iteration, + int MaxIterations, + bool FreeTierMode, + string? ActiveService, + int FreeTierDelaySeconds, + string? LatestUserPrompt, + string? LastUserPromptHookFingerprint, + string RunId, + int TotalToolCalls, + bool ForceInitialToolCallEnabled, + bool InjectPreCallToolReminder, + int NoToolCallLoopRetry, + bool HasRuntimeOverride, + int RuntimeAllowedToolCount); + +internal sealed record AgentLoopPreLlmStageResult( + string ThinkingSummary, + string? FreeTierDelaySummary, + int FreeTierDelayMilliseconds, + bool ShouldRunUserPromptSubmitHook, + string? UpdatedUserPromptFingerprint, + string? UserPromptSubmitHookPayloadJson, + AgentLoopLlmRequestPreparationResult? LlmRequest, + string? MissingToolsEventSummary, + string? MissingToolsReturnMessage); + +/// +/// Builds the pre-LLM stage decisions for an iteration: +/// status text, free-tier wait, prompt-submit hook plan, missing-tool guard, +/// and the final request payload assembly. +/// +internal static class AgentLoopPreLlmStageService +{ + public static AgentLoopPreLlmStageResult Prepare(AgentLoopPreLlmStageInput input) + { + var thinkingSummary = BuildThinkingSummary( + input.AgentLogLevel, + input.Iteration, + input.MaxIterations); + + var freeTierDelay = BuildFreeTierDelayPlan( + input.FreeTierMode, + input.ActiveService, + input.Iteration, + input.FreeTierDelaySeconds); + + var promptHookPlan = BuildUserPromptHookPlan( + input.LatestUserPrompt, + input.LastUserPromptHookFingerprint, + input.RunId, + input.ActiveTab); + + if (input.ActiveTools.Count == 0) + { + var (eventSummary, returnMessage) = BuildMissingToolsFailure( + input.ActiveTab, + input.HasRuntimeOverride, + input.RuntimeAllowedToolCount); + return new AgentLoopPreLlmStageResult( + thinkingSummary, + freeTierDelay.Summary, + freeTierDelay.DelayMilliseconds, + promptHookPlan.ShouldRunHook, + promptHookPlan.UpdatedFingerprint, + promptHookPlan.PayloadJson, + null, + eventSummary, + returnMessage); + } + + var llmRequest = AgentLoopQueryAssemblyService.PrepareRequest( + input.QueryMessages, + input.CodeWorkingSet, + input.TotalToolCalls, + input.ForceInitialToolCallEnabled, + input.InjectPreCallToolReminder, + input.NoToolCallLoopRetry); + + return new AgentLoopPreLlmStageResult( + thinkingSummary, + freeTierDelay.Summary, + freeTierDelay.DelayMilliseconds, + promptHookPlan.ShouldRunHook, + promptHookPlan.UpdatedFingerprint, + promptHookPlan.PayloadJson, + llmRequest, + null, + null); + } + + private static string BuildThinkingSummary(string? agentLogLevel, int iteration, int maxIterations) + { + var isDebugLog = string.Equals(agentLogLevel, "debug", StringComparison.OrdinalIgnoreCase); + return isDebugLog + ? $"Requesting the LLM... (iteration {iteration}/{maxIterations})" + : "Requesting the LLM..."; + } + + private static (string? Summary, int DelayMilliseconds) BuildFreeTierDelayPlan( + bool freeTierMode, + string? activeService, + int iteration, + int configuredDelaySeconds) + { + var shouldDelay = + freeTierMode + && iteration > 1 + && string.Equals((activeService ?? "").Trim(), "gemini", StringComparison.OrdinalIgnoreCase); + if (!shouldDelay) + return (null, 0); + + var delaySeconds = configuredDelaySeconds > 0 ? configuredDelaySeconds : 4; + return ($"Gemini free-tier wait: continue after {delaySeconds}s", delaySeconds * 1000); + } + + private static (bool ShouldRunHook, string? UpdatedFingerprint, string? PayloadJson) BuildUserPromptHookPlan( + string? latestUserPrompt, + string? lastUserPromptHookFingerprint, + string runId, + string? activeTab) + { + if (string.IsNullOrWhiteSpace(latestUserPrompt)) + return (false, lastUserPromptHookFingerprint, null); + + var fingerprint = $"{latestUserPrompt.Length}:{latestUserPrompt.GetHashCode()}"; + if (string.Equals(fingerprint, lastUserPromptHookFingerprint, StringComparison.Ordinal)) + return (false, lastUserPromptHookFingerprint, null); + + var payload = JsonSerializer.Serialize(new + { + prompt = Truncate(latestUserPrompt, 4000), + runId, + tab = activeTab + }); + return (true, fingerprint, payload); + } + + private static (string EventSummary, string ReturnMessage) BuildMissingToolsFailure( + string? activeTab, + bool hasRuntimeOverride, + int runtimeAllowedToolCount) + { + if (hasRuntimeOverride && runtimeAllowedToolCount > 0) + { + 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."); + } + + 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."); + } + + private static string Truncate(string text, int maxLength) + { + if (string.IsNullOrWhiteSpace(text) || text.Length <= maxLength) + return text; + + return text[..maxLength]; + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index bb7ea77..b1786b4 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -495,44 +495,6 @@ public partial class AgentLoopService AgentLoopDiagnosticsFormatter.BuildQueryViewSummary(queryView)); } - var isDebugLog = string.Equals(_settings.Settings.Llm.AgentLogLevel, "debug", StringComparison.OrdinalIgnoreCase); - EmitEvent(AgentEventType.Thinking, "", isDebugLog - ? $"LLM에 요청 중... (반복 {iteration}/{maxIterations})" - : "LLM에 요청 중..."); - - // Gemini 무료 티어 모드: LLM 호출 간 딜레이 (RPM 한도 초과 방지) - var activeService = (_settings.Settings.Llm.Service ?? "").Trim(); - var shouldApplyFreeTierDelay = - llm.FreeTierMode - && iteration > 1 - && string.Equals(activeService, "gemini", StringComparison.OrdinalIgnoreCase); - if (shouldApplyFreeTierDelay) - { - var delaySec = llm.FreeTierDelaySeconds > 0 ? llm.FreeTierDelaySeconds : 4; - EmitEvent(AgentEventType.Thinking, "", $"Gemini 무료 티어 대기: {delaySec}초 후 다음 호출을 진행합니다..."); - await Task.Delay(delaySec * 1000, ct); - } - - var latestUserPrompt = messages.LastOrDefault(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase))?.Content; - if (!string.IsNullOrWhiteSpace(latestUserPrompt)) - { - var fingerprint = $"{latestUserPrompt.Length}:{latestUserPrompt.GetHashCode()}"; - if (!string.Equals(fingerprint, lastUserPromptHookFingerprint, StringComparison.Ordinal)) - { - lastUserPromptHookFingerprint = fingerprint; - EmitEvent(AgentEventType.UserPromptSubmit, "", "사용자 프롬프트 제출"); - await RunRuntimeHooksAsync( - "__user_prompt_submit__", - "pre", - JsonSerializer.Serialize(new - { - prompt = TruncateOutput(latestUserPrompt, 4000), - runId = _currentRunId, - tab = ActiveTab - })); - } - } - // P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨 var cachedActiveTools = FilterExplorationToolsForCurrentIteration( GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides), @@ -541,31 +503,56 @@ public partial class AgentLoopService ActiveTab, totalToolCalls); + var preLlmStage = AgentLoopPreLlmStageService.Prepare(new AgentLoopPreLlmStageInput( + queryMessages, + cachedActiveTools, + codeWorkingSet, + ActiveTab, + _settings.Settings.Llm.AgentLogLevel, + iteration, + maxIterations, + llm.FreeTierMode, + _settings.Settings.Llm.Service, + llm.FreeTierDelaySeconds, + messages.LastOrDefault(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase))?.Content, + lastUserPromptHookFingerprint, + _currentRunId, + totalToolCalls, + executionPolicy.ForceInitialToolCall, + executionPolicy.InjectPreCallToolReminder, + runState.NoToolCallLoopRetry, + runtimeOverrides != null, + runtimeOverrides?.AllowedToolNames.Count ?? 0)); + + EmitEvent(AgentEventType.Thinking, "", preLlmStage.ThinkingSummary); + if (!string.IsNullOrWhiteSpace(preLlmStage.FreeTierDelaySummary) && preLlmStage.FreeTierDelayMilliseconds > 0) + { + EmitEvent(AgentEventType.Thinking, "", preLlmStage.FreeTierDelaySummary); + await Task.Delay(preLlmStage.FreeTierDelayMilliseconds, ct); + } + + if (preLlmStage.ShouldRunUserPromptSubmitHook && !string.IsNullOrWhiteSpace(preLlmStage.UserPromptSubmitHookPayloadJson)) + { + lastUserPromptHookFingerprint = preLlmStage.UpdatedUserPromptFingerprint; + EmitEvent(AgentEventType.UserPromptSubmit, "", "사용자 프롬프트 제출"); + await RunRuntimeHooksAsync( + "__user_prompt_submit__", + "pre", + preLlmStage.UserPromptSubmitHookPayloadJson); + } + // LLM에 도구 정의와 함께 요청 List blocks; var llmCallSw = Stopwatch.StartNew(); try { - var activeTools = cachedActiveTools; - if (activeTools.Count == 0) + if (!string.IsNullOrWhiteSpace(preLlmStage.MissingToolsReturnMessage)) { - if (runtimeOverrides != null && runtimeOverrides.AllowedToolNames.Count > 0) - { - EmitEvent(AgentEventType.Error, "", "현재 스킬 런타임 정책으로 사용 가능한 도구가 없습니다."); - return "⚠ 현재 스킬 정책에서 허용된 도구가 없어 작업을 진행할 수 없습니다. allowed-tools 설정을 확인하세요."; - } - - var tabLabel = string.IsNullOrWhiteSpace(ActiveTab) ? "현재" : ActiveTab; - EmitEvent(AgentEventType.Error, "", $"{tabLabel} 탭에서 사용 가능한 도구가 없습니다."); - return $"⚠ 현재 {tabLabel} 탭에서 사용 가능한 도구가 없어 작업을 진행할 수 없습니다. 탭별 도구 노출 정책을 확인하세요."; + EmitEvent(AgentEventType.Error, "", preLlmStage.MissingToolsEventSummary ?? "사용 가능한 도구가 없습니다."); + return preLlmStage.MissingToolsReturnMessage; } - var llmRequest = AgentLoopQueryAssemblyService.PrepareRequest( - queryMessages, - codeWorkingSet, - totalToolCalls, - executionPolicy.ForceInitialToolCall, - executionPolicy.InjectPreCallToolReminder, - runState.NoToolCallLoopRetry); + var activeTools = cachedActiveTools; + var llmRequest = preLlmStage.LlmRequest!; var forceFirst = llmRequest.ForceInitialToolCall; var sendMessages = llmRequest.SendMessages;