diff --git a/README.md b/README.md
index 78a14df..83a1a50 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,12 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저
개발 참고: Claw Code 동등성 작업 추적 문서
`docs/claw-code-parity-plan.md`
+- 업데이트: 2026-04-15 10:57 (KST)
+- AX Agent 루프의 도구 미호출 복구 규칙을 [AgentLoopNoToolResponseRecoveryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopNoToolResponseRecoveryService.cs)로 분리했습니다. `도구 미호출 루프`와 `계획만 세우고 실행하지 않는 경우`의 경고 문구, 재시도 횟수, 이벤트 요약을 별도 서비스에서 생성해 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)의 반복 분기를 더 읽기 쉽게 정리했습니다.
+- 새 테스트 [AgentLoopNoToolResponseRecoveryServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/AgentLoopNoToolResponseRecoveryServiceTests.cs)로 probe-only 즉시 복구, 최종 경고 전환, 계획 미실행 재시도 규칙을 회귀로 고정했습니다.
+- 검증: `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
+
- 업데이트: 2026-04-15 10:34 (KST)
- AX Agent 루프의 반복 준비 단계를 분리했습니다. [AgentLoopIterationPreparationService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopIterationPreparationService.cs)가 queued command 투영, tool result 대기 요약, query view 생성 책임을 묶어 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)의 반복 진입부를 더 가볍게 정리합니다.
- 긴 세션과 분기 대화에서 `tool_result` preview가 더 안정적으로 유지되도록 [AgentMessageInvariantHelper.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentMessageInvariantHelper.cs)와 [AgentToolResultBudget.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentToolResultBudget.cs)를 보강했습니다. 명시적 preview, fingerprint 재바인딩, synthetic preview의 우선순위를 분리해 저장/재개 후에도 사람이 보던 축약 결과를 우선 재사용합니다.
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index ffd520b..cc57e7e 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -25,6 +25,13 @@
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_loop_pipeline\\ -p:IntermediateOutputPath=obj\\verify_loop_pipeline\\` 경고 0 / 오류 0
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentQueuedCommandProjectorTests|AgentLoopIterationPreparationServiceTests|AgentMessageInvariantHelperTests|AgentToolResultBudgetTests|AgentQueryContextBuilderTests|ChatStorageServiceTests" -p:OutputPath=bin\\verify_loop_pipeline_tests\\ -p:IntermediateOutputPath=obj\\verify_loop_pipeline_tests\\` 통과 14
+업데이트: 2026-04-15 10:57 (KST)
+- Agent loop 마감 정리로 `src/AxCopilot/Services/Agent/AgentLoopNoToolResponseRecoveryService.cs`를 추가했습니다. `RunAsync()` 안에 섞여 있던 `도구 미호출 루프`와 `계획만 세우고 도구를 호출하지 않는 경우`의 복구 메시지/재시도 규칙/이벤트 요약을 별도 서비스로 분리해 orchestration 본문을 더 작게 유지합니다.
+- `src/AxCopilot/Services/Agent/AgentLoopService.cs`는 위 helper를 호출해 assistant 텍스트 보존, recovery user message 추가, retry counter 갱신, event emit만 수행합니다. 이로써 복구 규칙 변경 시 서비스 테스트만으로 회귀를 잡기 쉬워졌습니다.
+- 테스트: `src/AxCopilot.Tests/Services/AgentLoopNoToolResponseRecoveryServiceTests.cs`
+- 검증: `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
+
업데이트: 2026-04-14 19:50 (KST)
- Agent loop/queue/context 품질을 보강했습니다. `src/AxCopilot/Services/Agent/AgentCommandQueue.cs`로 실행 중 추가 입력을 우선순위와 interrupt 여부까지 포함해 관리하고, `AgentLoopService`는 이를 안전하게 반영합니다.
- `AgentToolResultBudget`, `AgentQueryContextBuilder`, `ChatModels`는 tool result preview를 메시지에 캐시해 긴 세션과 재질문에서도 같은 축약 결과를 재사용하도록 정리했습니다.
diff --git a/docs/NEXT_ROADMAP.md b/docs/NEXT_ROADMAP.md
index 672d99a..df3da7e 100644
--- a/docs/NEXT_ROADMAP.md
+++ b/docs/NEXT_ROADMAP.md
@@ -194,3 +194,5 @@
2. 루프/큐/컨텍스트 분리
3. 개발언어 fallback 심화
4. 명령/스킬 합성 및 릴리즈 게이트
+업데이트: 2026-04-15 10:57 (KST)
+- Agent loop 마감 작업으로 `도구 미호출 복구` 규칙을 서비스화했습니다. 남은 우선순위는 `iteration/tool dispatch 추가 분리`, `장기 세션 replacement state 완전 고정`, `문서 golden fixture 확대`, `릴리즈 체크리스트 닫기` 정도의 마감 품질 중심입니다.
diff --git a/src/AxCopilot.Tests/Services/AgentLoopNoToolResponseRecoveryServiceTests.cs b/src/AxCopilot.Tests/Services/AgentLoopNoToolResponseRecoveryServiceTests.cs
new file mode 100644
index 0000000..4c251e4
--- /dev/null
+++ b/src/AxCopilot.Tests/Services/AgentLoopNoToolResponseRecoveryServiceTests.cs
@@ -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();
+ }
+}
diff --git a/src/AxCopilot/Services/Agent/AgentLoopNoToolResponseRecoveryService.cs b/src/AxCopilot/Services/Agent/AgentLoopNoToolResponseRecoveryService.cs
new file mode 100644
index 0000000..0d1a06f
--- /dev/null
+++ b/src/AxCopilot/Services/Agent/AgentLoopNoToolResponseRecoveryService.cs
@@ -0,0 +1,107 @@
+namespace AxCopilot.Services.Agent;
+
+internal sealed record AgentLoopNoToolRecoveryResult(
+ int NextRetryCount,
+ string RecoveryContent,
+ string EventSummary);
+
+///
+/// 도구 미호출 응답에 대한 복구 메시지와 재시도 상태를 생성합니다.
+/// AgentLoopService 본체에서 분기/문구 조합 책임을 떼어내 테스트 가능하게 유지합니다.
+///
+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 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" +
+ "\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n\n" +
+ $"사용 가능한 도구: {activeToolPreview}",
+ _ => "[System:ToolCallRequired] " +
+ "🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " +
+ $"텍스트는 한 글자도 쓰지 말고 {preferredInitialToolSequence} 순서에 맞는 첫 도구를 고르세요. " +
+ "반드시 아래 형식으로 도구를 호출하세요:\n" +
+ "\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n\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 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" +
+ "\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n\n" +
+ $"사용 가능한 도구: {activeToolPreview}",
+ _ => "[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " +
+ $"텍스트를 한 글자도 쓰지 말고 {preferredInitialToolSequence} 순서에 맞는 도구 호출만 출력하세요:\n" +
+ "\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n\n" +
+ $"사용 가능한 도구: {activeToolPreview}"
+ };
+
+ var eventSummary = $"도구 미호출 감지 — 실행 재시도 {nextRetry}/{maxRetryCount}...";
+ return new AgentLoopNoToolRecoveryResult(nextRetry, recoveryContent, eventSummary);
+ }
+
+ internal static string BuildActiveToolPreview(IEnumerable activeToolNames)
+ {
+ return string.Join(", ",
+ activeToolNames
+ .Where(name => !string.IsNullOrWhiteSpace(name))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Take(10));
+ }
+}
diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs
index f398a44..f5e23bb 100644
--- a/src/AxCopilot/Services/Agent/AgentLoopService.cs
+++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs
@@ -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" +
- "\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n\n" +
- $"사용 가능한 도구: {activeToolPreview}",
- _ =>
- "[System:ToolCallRequired] " +
- "🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " +
- $"텍스트는 한 글자도 쓰지 말고 {preferredInitialToolSequence} 순서에 맞는 첫 도구를 고르세요. " +
- "반드시 아래 형식으로 도구를 호출하세요:\n" +
- "\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n\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" +
- "\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n\n" +
- $"사용 가능한 도구: {planToolList}",
- _ =>
- "[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " +
- $"텍스트를 한 글자도 쓰지 말고 {preferredInitialToolSequence} 순서에 맞는 도구 호출만 출력하세요:\n" +
- "\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n\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; // 루프 재시작
}