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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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; // 루프 재시작
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user