AX Agent 루프 마감 복구 규칙을 서비스화하고 회귀 검증을 고정합니다

- AgentLoopService 안에 섞여 있던 도구 미호출 루프/계획 미실행 복구 문구와 재시도 규칙을 AgentLoopNoToolResponseRecoveryService로 분리했습니다.

- probe-only 즉시 복구, 최종 경고 전환, 계획 미실행 재시도 규칙을 별도 테스트로 고정해 루프 마감 품질을 높였습니다.

- README.md, docs/DEVELOPMENT.md, docs/NEXT_ROADMAP.md에 2026-04-15 10:57 (KST) 기준 변경 이력과 검증 결과를 반영했습니다.

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_closeout\\ -p:IntermediateOutputPath=obj\\verify_closeout\\ (경고 0 / 오류 0)

- 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentLoopNoToolResponseRecoveryServiceTests|AgentLoopIterationPreparationServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentQueuedCommandProjectorTests|AgentMessageInvariantHelperTests|AgentToolResultBudgetTests|AgentQueryContextBuilderTests|ChatStorageServiceTests|HtmlSkillGoldenReportTests|PptxSkillGoldenDeckTests|DocxSkillGoldenDocumentTests|ExcelSkillGoldenWorkbookTests -p:OutputPath=bin\\verify_closeout_tests\\ -p:IntermediateOutputPath=obj\\verify_closeout_tests\\ (통과 27)
This commit is contained in:
2026-04-15 10:58:47 +09:00
parent 48e8c57cf3
commit f283662d30
6 changed files with 234 additions and 64 deletions

View File

@@ -0,0 +1,84 @@
using AxCopilot.Services.Agent;
using FluentAssertions;
using Xunit;
namespace AxCopilot.Tests.Services;
public class AgentLoopNoToolResponseRecoveryServiceTests
{
[Fact]
public void BuildNoToolLoopRecovery_ShouldTriggerImmediatelyForProbeOnlyRuns()
{
var result = AgentLoopNoToolResponseRecoveryService.BuildNoToolLoopRecovery(
requiresConcreteArtifactOrEdit: true,
onlyProbeToolsUsed: true,
totalToolCalls: 1,
consecutiveNoToolResponses: 1,
noToolResponseThreshold: 2,
currentRetryCount: 0,
maxRetryCount: 2,
preferredInitialToolSequence: "file_read -> file_edit",
activeToolNames: ["dev_env_detect", "file_read", "file_edit"]);
result.Should().NotBeNull();
result!.NextRetryCount.Should().Be(1);
result.RecoveryContent.Should().Contain("도구를 호출하지 않았습니다");
result.RecoveryContent.Should().Contain("file_read -> file_edit");
result.RecoveryContent.Should().Contain("dev_env_detect, file_read, file_edit");
}
[Fact]
public void BuildNoToolLoopRecovery_ShouldReturnFinalWarningOnLastRetry()
{
var result = AgentLoopNoToolResponseRecoveryService.BuildNoToolLoopRecovery(
requiresConcreteArtifactOrEdit: true,
onlyProbeToolsUsed: false,
totalToolCalls: 0,
consecutiveNoToolResponses: 2,
noToolResponseThreshold: 2,
currentRetryCount: 1,
maxRetryCount: 2,
preferredInitialToolSequence: "file_read -> file_edit",
activeToolNames: ["file_read", "file_edit", "file_read"]);
result.Should().NotBeNull();
result!.NextRetryCount.Should().Be(2);
result.RecoveryContent.Should().Contain("최종 경고");
result.RecoveryContent.Should().Contain("file_read, file_edit");
}
[Fact]
public void BuildPlanExecutionRecovery_ShouldGeneratePlanSpecificRetryPrompt()
{
var result = AgentLoopNoToolResponseRecoveryService.BuildPlanExecutionRecovery(
requiresConcreteArtifactOrEdit: true,
forceToolCallAfterPlan: true,
planStepCount: 3,
totalToolCalls: 0,
currentRetryCount: 0,
maxRetryCount: 2,
preferredInitialToolSequence: "file_read -> file_edit",
activeToolNames: ["file_read", "file_edit", "git_tool"]);
result.Should().NotBeNull();
result!.NextRetryCount.Should().Be(1);
result.RecoveryContent.Should().Contain("계획을 세웠지만 도구를 호출하지 않았습니다");
result.EventSummary.Should().Contain("실행 재시도 1/2");
}
[Fact]
public void BuildPlanExecutionRecovery_ShouldReturnNullWhenRetryIsExhausted()
{
var result = AgentLoopNoToolResponseRecoveryService.BuildPlanExecutionRecovery(
requiresConcreteArtifactOrEdit: true,
forceToolCallAfterPlan: true,
planStepCount: 2,
totalToolCalls: 0,
currentRetryCount: 2,
maxRetryCount: 2,
preferredInitialToolSequence: "file_read -> file_edit",
activeToolNames: ["file_read", "file_edit"]);
result.Should().BeNull();
}
}

View File

@@ -0,0 +1,107 @@
namespace AxCopilot.Services.Agent;
internal sealed record AgentLoopNoToolRecoveryResult(
int NextRetryCount,
string RecoveryContent,
string EventSummary);
/// <summary>
/// 도구 미호출 응답에 대한 복구 메시지와 재시도 상태를 생성합니다.
/// AgentLoopService 본체에서 분기/문구 조합 책임을 떼어내 테스트 가능하게 유지합니다.
/// </summary>
internal static class AgentLoopNoToolResponseRecoveryService
{
public static AgentLoopNoToolRecoveryResult? BuildNoToolLoopRecovery(
bool requiresConcreteArtifactOrEdit,
bool onlyProbeToolsUsed,
int totalToolCalls,
int consecutiveNoToolResponses,
int noToolResponseThreshold,
int currentRetryCount,
int maxRetryCount,
string preferredInitialToolSequence,
IEnumerable<string> activeToolNames)
{
if (!requiresConcreteArtifactOrEdit)
return null;
var hasNoSubstantiveToolUsage = totalToolCalls == 0 || onlyProbeToolsUsed;
if (!hasNoSubstantiveToolUsage)
return null;
var effectiveThreshold = onlyProbeToolsUsed ? 1 : noToolResponseThreshold;
if (consecutiveNoToolResponses < effectiveThreshold || currentRetryCount >= maxRetryCount)
return null;
var nextRetry = currentRetryCount + 1;
var activeToolPreview = BuildActiveToolPreview(activeToolNames);
var recoveryContent = nextRetry switch
{
1 => "[System:ToolCallRequired] " +
"⚠ 경고: 이전 응답에서 도구를 호출하지 않았습니다. " +
"텍스트 설명만 반환하는 것은 허용되지 않습니다. " +
$"먼저 권장 순서는 {preferredInitialToolSequence} 입니다. " +
"지금 즉시 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {activeToolPreview}",
_ => "[System:ToolCallRequired] " +
"🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " +
$"텍스트는 한 글자도 쓰지 말고 {preferredInitialToolSequence} 순서에 맞는 첫 도구를 고르세요. " +
"반드시 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"반드시 사용해야 할 도구 목록: {activeToolPreview}"
};
var eventSummary =
$"도구 미호출 루프 감지 — 강제 실행 유도 {nextRetry}/{maxRetryCount} (임계 {noToolResponseThreshold})";
return new AgentLoopNoToolRecoveryResult(nextRetry, recoveryContent, eventSummary);
}
public static AgentLoopNoToolRecoveryResult? BuildPlanExecutionRecovery(
bool requiresConcreteArtifactOrEdit,
bool forceToolCallAfterPlan,
int planStepCount,
int totalToolCalls,
int currentRetryCount,
int maxRetryCount,
string preferredInitialToolSequence,
IEnumerable<string> activeToolNames)
{
if (!requiresConcreteArtifactOrEdit
|| !forceToolCallAfterPlan
|| planStepCount <= 0
|| totalToolCalls != 0
|| currentRetryCount >= maxRetryCount)
{
return null;
}
var nextRetry = currentRetryCount + 1;
var activeToolPreview = BuildActiveToolPreview(activeToolNames);
var recoveryContent = nextRetry switch
{
1 => "[System:ToolCallRequired] 계획을 세웠지만 도구를 호출하지 않았습니다. " +
$"권장 첫 순서는 {preferredInitialToolSequence} 입니다. " +
"지금 당장 실행하세요. 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {activeToolPreview}",
_ => "[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " +
$"텍스트를 한 글자도 쓰지 말고 {preferredInitialToolSequence} 순서에 맞는 도구 호출만 출력하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {activeToolPreview}"
};
var eventSummary = $"도구 미호출 감지 — 실행 재시도 {nextRetry}/{maxRetryCount}...";
return new AgentLoopNoToolRecoveryResult(nextRetry, recoveryContent, eventSummary);
}
internal static string BuildActiveToolPreview(IEnumerable<string> activeToolNames)
{
return string.Join(", ",
activeToolNames
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(10));
}
}

View File

@@ -826,47 +826,24 @@ public partial class AgentLoopService
&& totalToolCalls > 0
&& !HasSubstantiveCodeToolUsage(statsUsedTools);
// probe-only 상태는 "1회 no-tool 응답"만으로도 즉시 복구 — 기본 임계(2회)는 느림
var effectiveNoToolThreshold = onlyProbeToolsUsed ? 1 : noToolResponseThreshold;
var noToolRecovery = AgentLoopNoToolResponseRecoveryService.BuildNoToolLoopRecovery(
requiresConcreteArtifactOrEdit,
onlyProbeToolsUsed,
totalToolCalls,
consecutiveNoToolResponses,
noToolResponseThreshold,
runState.NoToolCallLoopRetry,
noToolRecoveryMaxRetries,
preferredInitialToolSequence,
cachedActiveTools.Select(t => t.Name));
if (requiresConcreteArtifactOrEdit
&& (totalToolCalls == 0 || onlyProbeToolsUsed)
&& consecutiveNoToolResponses >= effectiveNoToolThreshold
&& runState.NoToolCallLoopRetry < noToolRecoveryMaxRetries)
if (noToolRecovery != null)
{
runState.NoToolCallLoopRetry++;
runState.NoToolCallLoopRetry = noToolRecovery.NextRetryCount;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
var activeToolPreview = string.Join(", ",
cachedActiveTools
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(10));
var retryNum = runState.NoToolCallLoopRetry;
var recoveryContent = retryNum switch
{
1 =>
"[System:ToolCallRequired] " +
"⚠ 경고: 이전 응답에서 도구를 호출하지 않았습니다. " +
"텍스트 설명만 반환하는 것은 허용되지 않습니다. " +
$"먼저 권장 순서는 {preferredInitialToolSequence} 입니다. " +
"지금 즉시 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {activeToolPreview}",
_ =>
"[System:ToolCallRequired] " +
"🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " +
$"텍스트는 한 글자도 쓰지 말고 {preferredInitialToolSequence} 순서에 맞는 첫 도구를 고르세요. " +
"반드시 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"반드시 사용해야 할 도구 목록: {activeToolPreview}"
};
messages.Add(new ChatMessage { Role = "user", Content = recoveryContent });
EmitEvent(
AgentEventType.Thinking,
"",
$"도구 미호출 루프 감지 — 강제 실행 유도 {runState.NoToolCallLoopRetry}/{noToolRecoveryMaxRetries} (임계 {noToolResponseThreshold})");
messages.Add(new ChatMessage { Role = "user", Content = noToolRecovery.RecoveryContent });
EmitEvent(AgentEventType.Thinking, "", noToolRecovery.EventSummary);
continue;
}
@@ -875,36 +852,23 @@ public partial class AgentLoopService
// 계획이 있고 도구가 아직 한 번도 실행되지 않은 경우 → LLM이 도구 대신 텍스트로만 응답한 것
// "계획이 승인됐으니 도구를 호출하라"는 메시지를 추가하여 재시도 (최대 2회)
if (requiresConcreteArtifactOrEdit
&& executionPolicy.ForceToolCallAfterPlan
&& planSteps.Count > 0
&& totalToolCalls == 0
&& planExecutionRetry < planExecutionRetryMax)
var planExecutionRecovery = AgentLoopNoToolResponseRecoveryService.BuildPlanExecutionRecovery(
requiresConcreteArtifactOrEdit,
executionPolicy.ForceToolCallAfterPlan,
planSteps.Count,
totalToolCalls,
planExecutionRetry,
planExecutionRetryMax,
preferredInitialToolSequence,
cachedActiveTools.Select(t => t.Name));
if (planExecutionRecovery != null)
{
planExecutionRetry++;
planExecutionRetry = planExecutionRecovery.NextRetryCount;
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
var planToolList = string.Join(", ",
cachedActiveTools
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(10));
var planRecoveryContent = planExecutionRetry switch
{
1 =>
"[System:ToolCallRequired] 계획을 세웠지만 도구를 호출하지 않았습니다. " +
$"권장 첫 순서는 {preferredInitialToolSequence} 입니다. " +
"지금 당장 실행하세요. 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {planToolList}",
_ =>
"[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " +
$"텍스트를 한 글자도 쓰지 말고 {preferredInitialToolSequence} 순서에 맞는 도구 호출만 출력하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {planToolList}"
};
messages.Add(new ChatMessage { Role = "user", Content = planRecoveryContent });
EmitEvent(AgentEventType.Thinking, "", $"도구 미호출 감지 — 실행 재시도 {planExecutionRetry}/{planExecutionRetryMax}...");
messages.Add(new ChatMessage { Role = "user", Content = planExecutionRecovery.RecoveryContent });
EmitEvent(AgentEventType.Thinking, "", planExecutionRecovery.EventSummary);
continue; // 루프 재시작
}