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; // 루프 재시작 }