?? ? ? ?? ?? ?? ??? ???? 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? ????. ???? ?? <tool_call>? 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)
This commit is contained in:
2026-04-15 14:02:23 +09:00
parent f3a31e97b1
commit 4403dc3fc3
10 changed files with 513 additions and 1 deletions

View File

@@ -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 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 - `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 안에 섞여 들어온 `<tool_call>` 블록을 복구해 `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

View File

@@ -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 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 - `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`
- 텍스트 블록 안에 `<tool_call>{...}</tool_call>` 형태로 섞여 들어온 호출을 `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

View File

@@ -507,6 +507,136 @@ public class AgentLoopE2ETests
events.Should().Contain(e => e.Type == AgentEventType.ToolResult && e.ToolName == "math_eval" && e.Success); 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 = "<html><body>clock</body></html>" }, "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<AgentEvent>();
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 = "<html><body>ok</body></html>" }, "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<AgentEvent>();
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": "<tool_call>{\"name\":\"file_write\",\"arguments\":{\"path\":\"index.html\",\"content\":\"<html><body>clock</body></html>\"}}</tool_call>\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<AgentEvent>();
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) private static SettingsService BuildLoopSettings(string endpoint)
{ {
var settings = new SettingsService(); var settings = new SettingsService();
@@ -520,8 +650,16 @@ public class AgentLoopE2ETests
settings.Settings.Llm.EnableToolHooks = false; settings.Settings.Llm.EnableToolHooks = false;
settings.Settings.Llm.EnableAutoRouter = false; settings.Settings.Llm.EnableAutoRouter = false;
settings.Settings.Llm.EnableForkSkillDelegationEnforcement = true; 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.FilePermission = "BypassPermissions";
settings.Settings.Llm.DefaultAgentPermission = "BypassPermissions"; settings.Settings.Llm.DefaultAgentPermission = "BypassPermissions";
settings.Settings.Llm.BlockedPaths.Clear();
settings.Settings.Llm.BlockedExtensions.Clear();
return settings; return settings;
} }
@@ -565,6 +703,9 @@ public class AgentLoopE2ETests
private static string BuildTextOnlyResponse(string content) private static string BuildTextOnlyResponse(string content)
=> BuildTextResponse(content); => BuildTextResponse(content);
private static string BuildRawResponse(string rawJson)
=> rawJson;
private sealed class FakeOllamaServer : IDisposable private sealed class FakeOllamaServer : IDisposable
{ {
private readonly HttpListener _listener; private readonly HttpListener _listener;

View File

@@ -62,4 +62,27 @@ public class AgentLoopResponseClassificationServiceTests
result.BuildThinkingSummary().Should().Be("실제 수정 범위를 다시 확인합니다."); result.BuildThinkingSummary().Should().Be("실제 수정 범위를 다시 확인합니다.");
} }
[Fact]
public void Classify_ShouldRecoverToolCallEmbeddedInText()
{
var blocks = new List<ContentBlock>
{
new()
{
Type = "text",
Text = """
<tool_call>{"name":"file_write","arguments":{"path":"index.html","content":"<html>clock</html>"}}</tool_call>
.
"""
}
};
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("<tool_call>");
result.NextConsecutiveNoToolResponses.Should().Be(0);
}
} }

View File

@@ -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<string> s_defaultSuppressedCodeMetaTools = new(StringComparer.OrdinalIgnoreCase)
{
"skill_manager",
"mcp_list_resources",
"mcp_read_resource"
};
private static readonly HashSet<string> 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<IAgentTool> ApplyCodeDefaultMetaToolFilter(
IReadOnlyCollection<IAgentTool> 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<ChatMessage> messages,
IReadOnlyCollection<string> 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<string> 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<ChatMessage> 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;
}
}
}

View File

@@ -202,7 +202,12 @@ public partial class AgentLoopService
{ {
// 문서 생성 의도가 감지되면 탐색 없이 바로 문서 계획/생성 도구 사용 // 문서 생성 의도가 감지되면 탐색 없이 바로 문서 계획/생성 도구 사용
if (state.Scope == ExplorationScope.DirectCreation) 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"; return "document_plan -> docx_create/html_create/excel_create -> self-review";
}
if (HasExplicitFolderIntent(userQuery)) if (HasExplicitFolderIntent(userQuery))
return "folder_map -> glob/grep -> targeted read"; return "folder_map -> glob/grep -> targeted read";
@@ -236,6 +241,10 @@ public partial class AgentLoopService
&& HasDocumentCreationIntent(lower)) && HasDocumentCreationIntent(lower))
return ExplorationScope.DirectCreation; return ExplorationScope.DirectCreation;
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& HasCodeArtifactCreationIntent(lower))
return ExplorationScope.DirectCreation;
if (lower.Contains("전체") || lower.Contains("전반") || lower.Contains("코드베이스 전체") || if (lower.Contains("전체") || lower.Contains("전반") || lower.Contains("코드베이스 전체") ||
lower.Contains("repo-wide") || lower.Contains("repository-wide") || lower.Contains("전체 구조") || lower.Contains("repo-wide") || lower.Contains("repository-wide") || lower.Contains("전체 구조") ||
lower.Contains("아키텍처") || 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<ChatMessage> messages, ExplorationScope scope) private static void InjectExplorationScopeGuidance(List<ChatMessage> messages, ExplorationScope scope)
{ {
var guidance = scope switch var guidance = scope switch
@@ -303,6 +330,16 @@ public partial class AgentLoopService
"Exploration scope = open-ended. Expand gradually. Prefer selective discovery before broad scans." "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 messages.Add(new ChatMessage
{ {
Role = "system", Role = "system",

View File

@@ -34,6 +34,21 @@ internal static class AgentLoopResponseClassificationService
toolCalls.Add(block); 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 var nextConsecutiveNoToolResponses = toolCalls.Count == 0
? consecutiveNoToolResponses + 1 ? consecutiveNoToolResponses + 1
: 0; : 0;

View File

@@ -289,6 +289,7 @@ public partial class AgentLoopService
string? lastModifiedCodeFilePath = null; string? lastModifiedCodeFilePath = null;
var context = BuildContext(); var context = BuildContext();
runState.WorkspaceAppearsEmpty = DetectEmptyWorkspace(context.WorkFolder);
var preferredInitialToolSequence = BuildPreferredInitialToolSequence( var preferredInitialToolSequence = BuildPreferredInitialToolSequence(
explorationState, explorationState,
@@ -300,12 +301,15 @@ public partial class AgentLoopService
"", "",
explorationState.Scope switch explorationState.Scope switch
{ {
ExplorationScope.DirectCreation => "문서 생성 모드 · 바로 문서를 만드는 중", ExplorationScope.DirectCreation => string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase)
? "즉시 생성 모드 · 바로 파일을 만드는 중"
: "문서 생성 모드 · 바로 문서를 만드는 중",
ExplorationScope.Localized => "국소 범위 탐색 · 관련 파일만 찾는 중", ExplorationScope.Localized => "국소 범위 탐색 · 관련 파일만 찾는 중",
ExplorationScope.TopicBased => "주제 범위 탐색 · 관련 후보만 추리는 중", ExplorationScope.TopicBased => "주제 범위 탐색 · 관련 후보만 추리는 중",
ExplorationScope.RepoWide => "저장소 범위 탐색 · 구조를 확인하는 중", ExplorationScope.RepoWide => "저장소 범위 탐색 · 구조를 확인하는 중",
_ => "점진 탐색 · 필요한 범위부터 확인하는 중", _ => "점진 탐색 · 필요한 범위부터 확인하는 중",
}); });
InjectInitialEmptyWorkspaceCreationGuidance(messages, explorationState, runState);
if (!executionPolicy.ReduceEarlyMemoryPressure) if (!executionPolicy.ReduceEarlyMemoryPressure)
InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy); InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy);
var runtimeOverrides = ResolveSkillRuntimeOverrides(messages); var runtimeOverrides = ResolveSkillRuntimeOverrides(messages);
@@ -1299,6 +1303,16 @@ public partial class AgentLoopService
continue; continue;
} }
if (TryHandleEmptyWorkspaceFallbackTransition(
effectiveCall,
context,
runState,
messages,
activeToolNames))
{
continue;
}
// Task Decomposition: 단계 진행률 추적 // Task Decomposition: 단계 진행률 추적
if (planSteps.Count > 0) if (planSteps.Count > 0)
{ {
@@ -1526,6 +1540,7 @@ public partial class AgentLoopService
ref statsFailCount, ref statsFailCount,
ref statsInputTokens, ref statsInputTokens,
ref statsOutputTokens); ref statsOutputTokens);
UpdateWorkspaceEmptyStateFromResult(runState, effectiveCall.ToolName, result);
lastToolResultAtUtc = DateTime.UtcNow; lastToolResultAtUtc = DateTime.UtcNow;
lastToolResultToolName = effectiveCall.ToolName; lastToolResultToolName = effectiveCall.ToolName;
@@ -1872,6 +1887,7 @@ public partial class AgentLoopService
// 탭별 도구 필터링: 현재 탭에 맞는 도구만 LLM에 전송 (토큰 절약) // 탭별 도구 필터링: 현재 탭에 맞는 도구만 LLM에 전송 (토큰 절약)
var active = _tools.GetActiveToolsForTab(ActiveTab, mergedDisabled); var active = _tools.GetActiveToolsForTab(ActiveTab, mergedDisabled);
active = ApplyPermissionExposureFilter(active); active = ApplyPermissionExposureFilter(active);
active = ApplyCodeDefaultMetaToolFilter(active, runtimeOverrides);
if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0) if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0)
return active; return active;

View File

@@ -1189,6 +1189,8 @@ public partial class AgentLoopService
public int DocumentVerificationGateRetry; public int DocumentVerificationGateRetry;
public int NoProgressRecoveryRetry; public int NoProgressRecoveryRetry;
public int TerminalEvidenceGateRetry; public int TerminalEvidenceGateRetry;
public bool WorkspaceAppearsEmpty;
public bool EmptyWorkspaceGuardTriggered;
public bool PendingPostCompactionTurn; public bool PendingPostCompactionTurn;
public int PostCompactionTurnCounter; public int PostCompactionTurnCounter;
public string LastCompactionStageSummary = ""; public string LastCompactionStageSummary = "";

View File

@@ -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("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("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다.");
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("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("");
sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble"); sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble");
sb.AppendLine("절대 금지 문구 — 도구 호출 전에 절대 쓰지 말 것:"); 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("A text-only response is fine once the requested code work is complete or enough evidence has been gathered.");
sb.AppendLine("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다."); 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("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("");
sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble"); sb.AppendLine("### [규칙 2] 말 먼저 하지 마라 — No Verbal Preamble");
sb.AppendLine("절대 금지 문구 — 도구 호출 전에 절대 쓰지 말 것:"); sb.AppendLine("절대 금지 문구 — 도구 호출 전에 절대 쓰지 말 것:");