?? ? ? ?? ?? ?? ??? ???? 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:
12
README.md
12
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 안에 섞여 들어온 `<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
|
||||
|
||||
@@ -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`
|
||||
- 텍스트 블록 안에 `<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
|
||||
|
||||
@@ -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 = "<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)
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -62,4 +62,27 @@ public class AgentLoopResponseClassificationServiceTests
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
224
src/AxCopilot/Services/Agent/AgentLoopCodeRuntimeGuards.cs
Normal file
224
src/AxCopilot/Services/Agent/AgentLoopCodeRuntimeGuards.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ChatMessage> 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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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("절대 금지 문구 — 도구 호출 전에 절대 쓰지 말 것:");
|
||||
|
||||
Reference in New Issue
Block a user