From 4403dc3fc34dbf905fe5d444b8debac7726b9e64 Mon Sep 17 00:00:00 2001 From: lacvet Date: Wed, 15 Apr 2026 14:02:23 +0900 Subject: [PATCH] ?? ? ? ?? ?? ?? ??? ???? file_write ?? ??? ?? ? ?? ???? folder_map, grep, file_read, env_tool, skill_manager, mcp_list_resources ?? ?? ???? ??? ??? AgentLoopCodeRuntimeGuards? ????, ?? ??(C:\ ?) fallback? ?? ? file_write ?? ?? ??? ????? ????. Code ??? ?? ?? ???? meta tool? ????, direct-creation ????? ?? ??? ?? ??? ?? ???? file_write ?? ???? ????? AgentLoopService? SystemPromptBuilder? ????. ???? ?? ? AgentLoopResponseClassificationService?? ??? file_write ??? ???. AgentLoopE2ETests? AgentLoopResponseClassificationServiceTests? ??? ? ?? ?? ?? fallback ??, skill_manager detour ??, text-embedded file_write ??? ??? ???? README.md? docs/DEVELOPMENT.md? 2026-04-15 14:00 (KST) ?? ??? ????. ??: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_empty_workspace_fix2\\ -p:IntermediateOutputPath=obj\\verify_empty_workspace_fix2\\ (?? 0 / ?? 0) - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "FullyQualifiedName~RunAsync_EmptyWorkspace_BlocksExternalFallbackAndRecoversToFileWrite|FullyQualifiedName~RunAsync_EmptyWorkspace_DisallowsSkillManagerAndRecoversToFileWrite|FullyQualifiedName~RunAsync_TextEmbeddedToolCall_RecoversAndExecutesFileWrite|FullyQualifiedName~Classify_ShouldRecoverToolCallEmbeddedInText" -p:OutputPath=bin\\verify_empty_workspace_fix2_tests\\ -p:IntermediateOutputPath=obj\\verify_empty_workspace_fix2_tests\\ (?? 4) --- README.md | 12 + docs/DEVELOPMENT.md | 38 +++ .../Services/AgentLoopE2ETests.cs | 141 +++++++++++ ...tLoopResponseClassificationServiceTests.cs | 23 ++ .../Agent/AgentLoopCodeRuntimeGuards.cs | 224 ++++++++++++++++++ .../Agent/AgentLoopExplorationPolicy.cs | 37 +++ .../AgentLoopResponseClassificationService.cs | 15 ++ .../Services/Agent/AgentLoopService.cs | 18 +- .../Agent/AgentLoopTransitions.Execution.cs | 2 + .../Views/ChatWindow.SystemPromptBuilder.cs | 4 + 10 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 src/AxCopilot/Services/Agent/AgentLoopCodeRuntimeGuards.cs diff --git a/README.md b/README.md index 62687d7..6579137 100644 --- a/README.md +++ b/README.md @@ -2051,3 +2051,15 @@ MIT License - 검증: - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_loop_sql_finalize\\ -p:IntermediateOutputPath=obj\\verify_loop_sql_finalize\\` 경고 0 / 오류 0 - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopResponseClassificationServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentLoopIterationPreparationServiceTests|SqlAnalysisServiceTests|SqlReviewServiceTests|CodeLanguageCatalogTests|WorkspaceContextGeneratorTests" -p:OutputPath=bin\\verify_loop_sql_finalize_tests\\ -p:IntermediateOutputPath=obj\\verify_loop_sql_finalize_tests\\` 통과 48 + +업데이트: 2026-04-15 14:00 (KST) +- Code 탭의 빈 작업 폴더 생성 버그를 보강했습니다. 새 [AgentLoopCodeRuntimeGuards.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopCodeRuntimeGuards.cs)는 빈 작업 폴더에서 `folder_map`, `grep`, `file_read`, `env_tool`, `skill_manager`, `mcp_list_resources`, `mcp_read_resource` 같은 우회 탐색을 차단하고, 외부 루트(`C:\\` 등)로의 fallback을 막은 뒤 `file_write` 직접 생성 경로로 복구합니다. +- [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)는 실행 시작 시 빈 작업 폴더를 감지하고, Code 탭의 direct-creation 요청이면 탐색보다 생성 우선 가이드를 먼저 주입하도록 정리했습니다. 같은 흐름에서 Code 기본 메타 도구 노출도 실제 런타임 활성 도구 목록에 반영되도록 연결했습니다. +- [ChatWindow.SystemPromptBuilder.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs)는 Code/Cowork 프롬프트에 `빈 작업 폴더 + 새 파일 생성 요청`일 때 `file_write`를 바로 호출하라는 규칙과 `skill_manager`, `mcp_list_resources`, `mcp_read_resource` 비사용 규칙을 추가했습니다. +- [AgentLoopResponseClassificationService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopResponseClassificationService.cs)는 text 안에 섞여 들어온 `` 블록을 복구해 `file_write` 같은 실제 도구 호출이 스트리밍 중 유실되지 않도록 보강했습니다. +- 테스트: + - [AgentLoopE2ETests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs) + - [AgentLoopResponseClassificationServiceTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/AgentLoopResponseClassificationServiceTests.cs) +- 검증: + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_empty_workspace_fix2\\ -p:IntermediateOutputPath=obj\\verify_empty_workspace_fix2\\` 경고 0 / 오류 0 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "FullyQualifiedName~RunAsync_EmptyWorkspace_BlocksExternalFallbackAndRecoversToFileWrite|FullyQualifiedName~RunAsync_EmptyWorkspace_DisallowsSkillManagerAndRecoversToFileWrite|FullyQualifiedName~RunAsync_TextEmbeddedToolCall_RecoversAndExecutesFileWrite|FullyQualifiedName~Classify_ShouldRecoverToolCallEmbeddedInText" -p:OutputPath=bin\\verify_empty_workspace_fix2_tests\\ -p:IntermediateOutputPath=obj\\verify_empty_workspace_fix2_tests\\` 통과 4 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 9d54db4..82b6cd3 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1298,3 +1298,41 @@ UI ?붿옄???€洹쒕え 由ы뙥?좊쭅 ???꾪뿕 ?묒뾽 ??湲곕줉???덉쟾 ### 검증 - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_loop_sql_finalize\\ -p:IntermediateOutputPath=obj\\verify_loop_sql_finalize\\` 경고 0 / 오류 0 - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopResponseClassificationServiceTests|AgentLoopLlmRequestPreparationServiceTests|AgentLoopIterationPreparationServiceTests|SqlAnalysisServiceTests|SqlReviewServiceTests|CodeLanguageCatalogTests|WorkspaceContextGeneratorTests" -p:OutputPath=bin\\verify_loop_sql_finalize_tests\\ -p:IntermediateOutputPath=obj\\verify_loop_sql_finalize_tests\\` 통과 48 + +업데이트: 2026-04-15 14:00 (KST) + +### Code 탭 빈 작업 폴더 생성 버그 보강 +- 새 `AgentLoopCodeRuntimeGuards.cs` + - `skill_manager`, `mcp_list_resources`, `mcp_read_resource`를 Code 기본 런타임 도구 노출에서 제외하는 `ApplyCodeDefaultMetaToolFilter()`를 추가했습니다. + - 빈 작업 폴더에서 `folder_map`, `glob`, `grep`, `code_search`, `file_read`, `env_tool`, `skill_manager`, `mcp_*` 도구로 우회 탐색을 시도하면 `TryHandleEmptyWorkspaceFallbackTransition()`이 차단하고, `file_write` 직접 생성 복구 프롬프트를 주입하도록 만들었습니다. + - 상대 경로 `.`도 작업 폴더 기준으로 해석해 외부 루트 탐색과 detour를 구분하도록 `IsExternalWorkspaceEscalationTarget()`를 보정했습니다. + - direct-creation 요청이면서 작업 폴더가 비어 있으면 시작 시점에 곧바로 `file_write` 생성 우선 가이드를 주입하는 `InjectInitialEmptyWorkspaceCreationGuidance()`를 추가했습니다. + +### AgentLoop / Code 프롬프트 연동 +- `AgentLoopService.cs` + - `BuildContext()` 직후 `DetectEmptyWorkspace(context.WorkFolder)`로 빈 작업 폴더를 감지해 `runState.WorkspaceAppearsEmpty`에 반영합니다. + - `GetRuntimeActiveTools()`에서 `ApplyCodeDefaultMetaToolFilter()`를 실제 런타임 도구 목록에 적용합니다. + - 도구 실행 직전 `TryHandleEmptyWorkspaceFallbackTransition()`을 호출해 외부 루트 fallback과 메타 도구 detour를 막습니다. + - direct-creation 상태 메시지는 Code 탭에서 `즉시 생성 모드 · 바로 파일을 만드는 중`으로 분리해 사용자에게 현재 의도를 더 정확히 보이도록 정리했습니다. +- `ChatWindow.SystemPromptBuilder.cs` + - Code/Cowork 프롬프트에 `빈 작업 폴더 + 새 파일/웹페이지/scaffold 생성 요청`이면 broad exploration 없이 `file_write`를 바로 호출하라는 규칙을 추가했습니다. + - Code 일반 작업에서 `skill_manager`, `mcp_list_resources`, `mcp_read_resource`를 쓰지 말라는 규칙도 함께 추가했습니다. + +### Tool-call 정합성 복구 +- `AgentLoopResponseClassificationService.cs` + - 텍스트 블록 안에 `{...}` 형태로 섞여 들어온 호출을 `LlmService.TryExtractToolCallsFromText()`로 복구하도록 확장했습니다. + - `LlmService.StripToolCallTokens()`로 남은 텍스트는 thinking/assistant 요약에만 남기고 실제 도구 호출은 실행 경로로 넘깁니다. + - 이 보강으로 `file_write`가 스트리밍 중 텍스트에 묻혀 유실되는 케이스를 줄였습니다. + +### 테스트 +- `AgentLoopE2ETests.cs` + - `RunAsync_EmptyWorkspace_BlocksExternalFallbackAndRecoversToFileWrite()` + - `RunAsync_EmptyWorkspace_DisallowsSkillManagerAndRecoversToFileWrite()` + - `RunAsync_TextEmbeddedToolCall_RecoversAndExecutesFileWrite()` + - E2E helper `BuildLoopSettings()`는 ambient project/plugin/MCP skill discovery를 꺼서 현재 저장소 스킬 상태에 영향받지 않도록 격리했습니다. +- `AgentLoopResponseClassificationServiceTests.cs` + - `Classify_ShouldRecoverToolCallEmbeddedInText()` 추가 + +### 검증 +- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_empty_workspace_fix2\\ -p:IntermediateOutputPath=obj\\verify_empty_workspace_fix2\\` 경고 0 / 오류 0 +- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "FullyQualifiedName~RunAsync_EmptyWorkspace_BlocksExternalFallbackAndRecoversToFileWrite|FullyQualifiedName~RunAsync_EmptyWorkspace_DisallowsSkillManagerAndRecoversToFileWrite|FullyQualifiedName~RunAsync_TextEmbeddedToolCall_RecoversAndExecutesFileWrite|FullyQualifiedName~Classify_ShouldRecoverToolCallEmbeddedInText" -p:OutputPath=bin\\verify_empty_workspace_fix2_tests\\ -p:IntermediateOutputPath=obj\\verify_empty_workspace_fix2_tests\\` 통과 4 diff --git a/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs b/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs index 4349237..2d54faf 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs @@ -507,6 +507,136 @@ public class AgentLoopE2ETests events.Should().Contain(e => e.Type == AgentEventType.ToolResult && e.ToolName == "math_eval" && e.Success); } + [Fact] + public async Task RunAsync_EmptyWorkspace_BlocksExternalFallbackAndRecoversToFileWrite() + { + var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-empty-workspace-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + using var server = new FakeOllamaServer( + [ + BuildToolCallResponse("folder_map", new { path = ".", depth = 2, include_files = true }, "empty workspace check"), + BuildToolCallResponse("grep", new { pattern = "\\.html$", path = @"C:\\", files_only = true }, "bad external fallback"), + BuildToolCallResponse("file_write", new { path = "index.html", content = "clock" }, "create file directly"), + BuildTextResponse("완료"), + ]); + + var settings = BuildLoopSettings(server.Endpoint); + settings.Settings.Llm.WorkFolder = tempDir; + using var llm = new LlmService(settings); + using var tools = ToolRegistry.CreateDefault(); + var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" }; + + var events = new List(); + loop.EventOccurred += evt => events.Add(evt); + + var result = await loop.RunAsync( + [ + new ChatMessage { Role = "user", Content = "실시간으로 시간을 표시하는 웹페이지 만들어줘" } + ]); + + + result.Should().Contain("완료"); + File.Exists(Path.Combine(tempDir, "index.html")).Should().BeTrue(); + events.Should().Contain(e => + e.Type == AgentEventType.Error + && (e.ToolName == "grep" || e.ToolName == "folder_map") + && e.Summary.Contains("Empty workspace guard blocked", StringComparison.OrdinalIgnoreCase)); + events.Should().Contain(e => e.Type == AgentEventType.ToolCall && e.ToolName == "file_write"); + events.Should().NotContain(e => e.Type == AgentEventType.PermissionRequest && e.ToolName == "grep"); + } + finally + { + try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { } + } + } + + [Fact] + public async Task RunAsync_EmptyWorkspace_DisallowsSkillManagerAndRecoversToFileWrite() + { + var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-empty-workspace-skill-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + using var server = new FakeOllamaServer( + [ + BuildToolCallResponse("folder_map", new { path = ".", depth = 2, include_files = true }, "empty workspace check"), + BuildToolCallResponse("skill_manager", new { action = "list" }, "irrelevant meta tool"), + BuildToolCallResponse("file_write", new { path = "index.html", content = "ok" }, "create file"), + BuildTextResponse("완료"), + ]); + + var settings = BuildLoopSettings(server.Endpoint); + settings.Settings.Llm.WorkFolder = tempDir; + using var llm = new LlmService(settings); + using var tools = ToolRegistry.CreateDefault(); + var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" }; + + var events = new List(); + loop.EventOccurred += evt => events.Add(evt); + + var result = await loop.RunAsync( + [ + new ChatMessage { Role = "user", Content = "빈 폴더에 index.html 만들어줘" } + ]); + + + result.Should().Contain("완료"); + File.Exists(Path.Combine(tempDir, "index.html")).Should().BeTrue(); + events.Should().Contain(e => e.Type == AgentEventType.Error && e.ToolName == "skill_manager"); + events.Should().Contain(e => e.Type == AgentEventType.ToolCall && e.ToolName == "file_write"); + } + finally + { + try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { } + } + } + + [Fact] + public async Task RunAsync_TextEmbeddedToolCall_RecoversAndExecutesFileWrite() + { + var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-text-toolcall-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + using var server = new FakeOllamaServer( + [ + BuildRawResponse(""" + { + "message": { + "content": "{\"name\":\"file_write\",\"arguments\":{\"path\":\"index.html\",\"content\":\"clock\"}}\n파일을 작성합니다." + } + } + """), + BuildTextResponse("완료"), + ]); + + var settings = BuildLoopSettings(server.Endpoint); + settings.Settings.Llm.WorkFolder = tempDir; + using var llm = new LlmService(settings); + using var tools = ToolRegistry.CreateDefault(); + var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" }; + + var events = new List(); + loop.EventOccurred += evt => events.Add(evt); + + var result = await loop.RunAsync( + [ + new ChatMessage { Role = "user", Content = "index.html 파일 만들어줘" } + ]); + + + result.Should().Contain("완료"); + File.Exists(Path.Combine(tempDir, "index.html")).Should().BeTrue(); + events.Should().Contain(e => e.Type == AgentEventType.ToolCall && e.ToolName == "file_write"); + } + finally + { + try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { } + } + } + private static SettingsService BuildLoopSettings(string endpoint) { var settings = new SettingsService(); @@ -520,8 +650,16 @@ public class AgentLoopE2ETests settings.Settings.Llm.EnableToolHooks = false; settings.Settings.Llm.EnableAutoRouter = false; settings.Settings.Llm.EnableForkSkillDelegationEnforcement = true; + settings.Settings.Llm.EnableProjectSkillDiscovery = false; + settings.Settings.Llm.EnablePluginSkillDiscovery = false; + settings.Settings.Llm.EnableMcpSkillDiscovery = false; + settings.Settings.Llm.EnableLegacyCommandSkills = false; + settings.Settings.Llm.SkillsFolderPath = ""; + settings.Settings.Llm.AdditionalSkillFolders.Clear(); settings.Settings.Llm.FilePermission = "BypassPermissions"; settings.Settings.Llm.DefaultAgentPermission = "BypassPermissions"; + settings.Settings.Llm.BlockedPaths.Clear(); + settings.Settings.Llm.BlockedExtensions.Clear(); return settings; } @@ -565,6 +703,9 @@ public class AgentLoopE2ETests private static string BuildTextOnlyResponse(string content) => BuildTextResponse(content); + private static string BuildRawResponse(string rawJson) + => rawJson; + private sealed class FakeOllamaServer : IDisposable { private readonly HttpListener _listener; diff --git a/src/AxCopilot.Tests/Services/AgentLoopResponseClassificationServiceTests.cs b/src/AxCopilot.Tests/Services/AgentLoopResponseClassificationServiceTests.cs index d4dac4a..c02a0e3 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopResponseClassificationServiceTests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopResponseClassificationServiceTests.cs @@ -62,4 +62,27 @@ public class AgentLoopResponseClassificationServiceTests result.BuildThinkingSummary().Should().Be("실제 수정 범위를 다시 확인합니다."); } + [Fact] + public void Classify_ShouldRecoverToolCallEmbeddedInText() + { + var blocks = new List + { + new() + { + Type = "text", + Text = """ + {"name":"file_write","arguments":{"path":"index.html","content":"clock"}} + 이제 파일을 생성합니다. + """ + } + }; + + var result = AgentLoopResponseClassificationService.Classify(blocks, consecutiveNoToolResponses: 0); + + result.ToolCalls.Should().ContainSingle(); + result.ToolCalls[0].ToolName.Should().Be("file_write"); + result.TextResponse.Should().Contain("이제 파일을 생성합니다."); + result.TextResponse.Should().NotContain(""); + result.NextConsecutiveNoToolResponses.Should().Be(0); + } } diff --git a/src/AxCopilot/Services/Agent/AgentLoopCodeRuntimeGuards.cs b/src/AxCopilot/Services/Agent/AgentLoopCodeRuntimeGuards.cs new file mode 100644 index 0000000..b65b5f9 --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopCodeRuntimeGuards.cs @@ -0,0 +1,224 @@ +using System.IO; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Services.Agent; + +public partial class AgentLoopService +{ + private static readonly HashSet s_defaultSuppressedCodeMetaTools = new(StringComparer.OrdinalIgnoreCase) + { + "skill_manager", + "mcp_list_resources", + "mcp_read_resource" + }; + + private static readonly HashSet s_emptyWorkspaceSearchTools = new(StringComparer.OrdinalIgnoreCase) + { + "folder_map", + "glob", + "grep", + "code_search", + "file_read", + "env_tool", + "skill_manager", + "mcp_list_resources", + "mcp_read_resource" + }; + + private IReadOnlyCollection ApplyCodeDefaultMetaToolFilter( + IReadOnlyCollection tools, + SkillRuntimeOverrides? runtimeOverrides) + { + if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)) + return tools; + + if (runtimeOverrides?.AllowedToolNames?.Count > 0) + return tools; + + return tools + .Where(tool => !s_defaultSuppressedCodeMetaTools.Contains(tool.Name)) + .ToList() + .AsReadOnly(); + } + + private bool TryHandleEmptyWorkspaceFallbackTransition( + ContentBlock call, + AgentContext context, + RunState runState, + List messages, + IReadOnlyCollection activeToolNames) + { + if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)) + return false; + + if (!runState.WorkspaceAppearsEmpty) + return false; + + var toolName = call.ToolName ?? ""; + if (!s_emptyWorkspaceSearchTools.Contains(toolName)) + return false; + + var target = DescribeToolTarget(toolName, call.ToolInput ?? default, context); + var isExternalEscalation = IsExternalWorkspaceEscalationTarget(target, context.WorkFolder); + + runState.EmptyWorkspaceGuardTriggered = true; + + var blockedMessage = isExternalEscalation + ? $"Empty workspace guard blocked external path search: {target}" + : $"Empty workspace guard blocked detour tool '{toolName}'. Use file_write directly."; + + EmitEvent(AgentEventType.Error, toolName, blockedMessage); + EmitEvent( + AgentEventType.Thinking, + "", + "Empty workspace detected. Stopping broad exploration and switching to direct file creation."); + + messages.Add(LlmService.CreateToolResultMessage( + call.ToolId, + toolName, + blockedMessage)); + messages.Add(new ChatMessage + { + Role = "user", + Content = BuildEmptyWorkspaceCreationRecoveryPrompt(activeToolNames) + }); + return true; + } + + private static string BuildEmptyWorkspaceCreationRecoveryPrompt(IReadOnlyCollection activeToolNames) + { + var activeToolPreview = AgentLoopNoToolResponseRecoveryService.BuildActiveToolPreview(activeToolNames); + return "[System:EmptyWorkspaceCreation] The current work folder is empty. " + + "Do not search C:\\, other drive roots, or repeat folder_map, grep, glob, file_read, env_tool, skill_manager, or mcp tools. " + + "Call file_write immediately using a relative path in the current work folder and create the needed file directly. " + + "For example: index.html, app.js, or style.css. " + + "Do not only describe the plan. Emit the actual tool call now. " + + $"Available tools: {activeToolPreview}"; + } + + private void InjectInitialEmptyWorkspaceCreationGuidance( + List messages, + ExplorationTrackingState explorationState, + RunState runState) + { + if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)) + return; + + if (!runState.WorkspaceAppearsEmpty) + return; + + if (explorationState.Scope != ExplorationScope.DirectCreation) + return; + + runState.EmptyWorkspaceGuardTriggered = true; + + messages.Add(new ChatMessage + { + Role = "system", + Content = + "[System:EmptyWorkspaceStart] The current work folder is empty and the user asked to create a new artifact. " + + "Skip broad exploration. Do not call folder_map, glob, grep, file_read, env_tool, skill_manager, mcp_list_resources, or mcp_read_resource unless the user explicitly asks to inspect the workspace. " + + "Call file_write immediately with a relative path inside the current work folder and create the requested file directly." + }); + + EmitEvent( + AgentEventType.Thinking, + "", + "빈 작업 폴더 감지 · 탐색 없이 새 파일 생성을 시작합니다"); + } + + private static bool IsExternalWorkspaceEscalationTarget(string? target, string? workFolder) + { + if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(workFolder)) + return false; + + try + { + var fullWorkFolder = Path.GetFullPath(workFolder); + var fullTarget = Path.IsPathRooted(target) + ? Path.GetFullPath(target) + : Path.GetFullPath(Path.Combine(fullWorkFolder, target)); + var root = Path.GetPathRoot(fullTarget); + + if (!string.IsNullOrWhiteSpace(root) + && string.Equals( + fullTarget.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return !IsSubPathOf(fullTarget, fullWorkFolder); + } + catch + { + return false; + } + } + + private static bool IsSubPathOf(string candidatePath, string basePath) + { + var normalizedBase = Path.GetFullPath(basePath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + var normalizedCandidate = Path.GetFullPath(candidatePath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + return normalizedCandidate.StartsWith(normalizedBase, StringComparison.OrdinalIgnoreCase) + || string.Equals( + normalizedCandidate.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + normalizedBase.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase); + } + + private static void UpdateWorkspaceEmptyStateFromResult(RunState runState, string toolName, ToolResult result) + { + if (!result.Success) + return; + + if (string.Equals(toolName, "folder_map", StringComparison.OrdinalIgnoreCase)) + { + runState.WorkspaceAppearsEmpty = LooksLikeEmptyFolderMap(result.Output); + if (!runState.WorkspaceAppearsEmpty) + runState.EmptyWorkspaceGuardTriggered = false; + return; + } + + if (IsFileModifyingTool(toolName) && !string.IsNullOrWhiteSpace(result.FilePath)) + { + runState.WorkspaceAppearsEmpty = false; + runState.EmptyWorkspaceGuardTriggered = false; + } + } + + private static bool LooksLikeEmptyFolderMap(string? output) + { + if (string.IsNullOrWhiteSpace(output)) + return false; + + var lower = output.ToLowerInvariant(); + return lower.Contains("0 files") + && lower.Contains("0 dirs"); + } + + private static bool DetectEmptyWorkspace(string? workFolder) + { + if (string.IsNullOrWhiteSpace(workFolder)) + return false; + + try + { + if (!Directory.Exists(workFolder)) + return false; + + using var enumerator = Directory.EnumerateFileSystemEntries(workFolder).GetEnumerator(); + return !enumerator.MoveNext(); + } + catch + { + return false; + } + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs b/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs index 3237585..40f8473 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs @@ -202,7 +202,12 @@ public partial class AgentLoopService { // 문서 생성 의도가 감지되면 탐색 없이 바로 문서 계획/생성 도구 사용 if (state.Scope == ExplorationScope.DirectCreation) + { + if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)) + return "file_write -> file_edit -> build_run/test_loop as needed"; + return "document_plan -> docx_create/html_create/excel_create -> self-review"; + } if (HasExplicitFolderIntent(userQuery)) return "folder_map -> glob/grep -> targeted read"; @@ -236,6 +241,10 @@ public partial class AgentLoopService && HasDocumentCreationIntent(lower)) return ExplorationScope.DirectCreation; + if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase) + && HasCodeArtifactCreationIntent(lower)) + return ExplorationScope.DirectCreation; + if (lower.Contains("전체") || lower.Contains("전반") || lower.Contains("코드베이스 전체") || lower.Contains("repo-wide") || lower.Contains("repository-wide") || lower.Contains("전체 구조") || lower.Contains("아키텍처") || lower.Contains("전체 점검")) @@ -283,6 +292,24 @@ public partial class AgentLoopService "정리해", "정리 해"); } + private static bool HasCodeArtifactCreationIntent(string lowerQuery) + { + var hasCreationVerb = ContainsAny( + lowerQuery, + "만들", "생성", "작성", "create", "generate", "build", "write", "scaffold", "draft"); + + if (!hasCreationVerb) + return false; + + return ContainsAny( + lowerQuery, + "html", "css", "javascript", "js", "typescript", "ts", + "web page", "webpage", "website", "landing page", + "웹페이지", "웹 페이지", "웹사이트", "페이지", + "file", "파일", "script", "스크립트", "component", "컴포넌트", + "template", "템플릿", "index.html", "app.js", "style.css"); + } + private static void InjectExplorationScopeGuidance(List messages, ExplorationScope scope) { var guidance = scope switch @@ -303,6 +330,16 @@ public partial class AgentLoopService "Exploration scope = open-ended. Expand gradually. Prefer selective discovery before broad scans." }; + if (scope == ExplorationScope.DirectCreation) + { + guidance = + "Exploration scope = direct-creation. The user wants to CREATE a new file or document. " + + "Do NOT search for existing files with glob/grep/folder_map unless the user explicitly asked to inspect the workspace. " + + "If you are in the Code tab, call file_write immediately with a relative path inside the current work folder, then use file_edit/build_run/test_loop only if needed. " + + "If you are in Cowork, call document_plan first and then the appropriate creation tool. " + + "The output MUST be a real file on disk, not a text response."; + } + messages.Add(new ChatMessage { Role = "system", diff --git a/src/AxCopilot/Services/Agent/AgentLoopResponseClassificationService.cs b/src/AxCopilot/Services/Agent/AgentLoopResponseClassificationService.cs index 64d9697..3fb141d 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopResponseClassificationService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopResponseClassificationService.cs @@ -34,6 +34,21 @@ internal static class AgentLoopResponseClassificationService toolCalls.Add(block); } + if (toolCalls.Count == 0 && textParts.Count > 0) + { + var mergedText = string.Join("\n", textParts); + var recoveredToolCalls = LlmService.TryExtractToolCallsFromText(mergedText); + if (recoveredToolCalls.Count > 0) + { + toolCalls.AddRange(recoveredToolCalls); + textParts.Clear(); + + var stripped = LlmService.StripToolCallTokens(mergedText); + if (!string.IsNullOrWhiteSpace(stripped)) + textParts.Add(stripped); + } + } + var nextConsecutiveNoToolResponses = toolCalls.Count == 0 ? consecutiveNoToolResponses + 1 : 0; diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index d1c1df1..89a791e 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -289,6 +289,7 @@ public partial class AgentLoopService string? lastModifiedCodeFilePath = null; var context = BuildContext(); + runState.WorkspaceAppearsEmpty = DetectEmptyWorkspace(context.WorkFolder); var preferredInitialToolSequence = BuildPreferredInitialToolSequence( explorationState, @@ -300,12 +301,15 @@ public partial class AgentLoopService "", explorationState.Scope switch { - ExplorationScope.DirectCreation => "문서 생성 모드 · 바로 문서를 만드는 중", + ExplorationScope.DirectCreation => string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase) + ? "즉시 생성 모드 · 바로 파일을 만드는 중" + : "문서 생성 모드 · 바로 문서를 만드는 중", ExplorationScope.Localized => "국소 범위 탐색 · 관련 파일만 찾는 중", ExplorationScope.TopicBased => "주제 범위 탐색 · 관련 후보만 추리는 중", ExplorationScope.RepoWide => "저장소 범위 탐색 · 구조를 확인하는 중", _ => "점진 탐색 · 필요한 범위부터 확인하는 중", }); + InjectInitialEmptyWorkspaceCreationGuidance(messages, explorationState, runState); if (!executionPolicy.ReduceEarlyMemoryPressure) InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy); var runtimeOverrides = ResolveSkillRuntimeOverrides(messages); @@ -1299,6 +1303,16 @@ public partial class AgentLoopService continue; } + if (TryHandleEmptyWorkspaceFallbackTransition( + effectiveCall, + context, + runState, + messages, + activeToolNames)) + { + continue; + } + // Task Decomposition: 단계 진행률 추적 if (planSteps.Count > 0) { @@ -1526,6 +1540,7 @@ public partial class AgentLoopService ref statsFailCount, ref statsInputTokens, ref statsOutputTokens); + UpdateWorkspaceEmptyStateFromResult(runState, effectiveCall.ToolName, result); lastToolResultAtUtc = DateTime.UtcNow; lastToolResultToolName = effectiveCall.ToolName; @@ -1872,6 +1887,7 @@ public partial class AgentLoopService // 탭별 도구 필터링: 현재 탭에 맞는 도구만 LLM에 전송 (토큰 절약) var active = _tools.GetActiveToolsForTab(ActiveTab, mergedDisabled); active = ApplyPermissionExposureFilter(active); + active = ApplyCodeDefaultMetaToolFilter(active, runtimeOverrides); if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0) return active; diff --git a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs index 28d7416..22dc6c5 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs @@ -1189,6 +1189,8 @@ public partial class AgentLoopService public int DocumentVerificationGateRetry; public int NoProgressRecoveryRetry; public int TerminalEvidenceGateRetry; + public bool WorkspaceAppearsEmpty; + public bool EmptyWorkspaceGuardTriggered; public bool PendingPostCompactionTurn; public int PostCompactionTurnCounter; public string LastCompactionStageSummary = ""; diff --git a/src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs b/src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs index 7bdc952..63acf59 100644 --- a/src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs +++ b/src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs @@ -31,6 +31,8 @@ public partial class ChatWindow sb.AppendLine("A text-only response is fine once the requested artifact already exists, the requested analysis is complete, or enough evidence has been gathered."); sb.AppendLine("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다."); sb.AppendLine("When a tool is clearly useful, call it promptly without a long preamble. Do not force an unnecessary tool call if the task is already complete."); + sb.AppendLine("If the current work folder is empty and the user is asking to create a new file, webpage, or scaffold, skip broad exploration and call file_write directly with a relative path inside the current work folder."); + sb.AppendLine("Do not call skill_manager, mcp_list_resources, or mcp_read_resource for normal Code tasks unless the user explicitly asked about skills or MCP resources."); sb.AppendLine(""); sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble"); sb.AppendLine("절대 금지 문구 — 도구 호출 전에 절대 쓰지 말 것:"); @@ -206,6 +208,8 @@ public partial class ChatWindow sb.AppendLine("A text-only response is fine once the requested code work is complete or enough evidence has been gathered."); sb.AppendLine("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다."); sb.AppendLine("When a tool is clearly useful, call it promptly without a long preamble. Do not force an unnecessary tool call if the task is already complete."); + sb.AppendLine("If the current work folder is empty and the user is asking to create a new file, webpage, or scaffold, skip broad exploration and call file_write directly with a relative path inside the current work folder."); + sb.AppendLine("Do not call skill_manager, mcp_list_resources, or mcp_read_resource for normal Code tasks unless the user explicitly asked about skills or MCP resources."); sb.AppendLine(""); sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble"); sb.AppendLine("절대 금지 문구 — 도구 호출 전에 절대 쓰지 말 것:");