?? ? ? ?? ?? ?? ??? ???? 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

@@ -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 (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",

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 = "";

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("출력의 첫 번째 항목은 반드시 도구 호출이어야 합니다. 텍스트로 시작하는 것은 금지입니다.");
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("절대 금지 문구 — 도구 호출 전에 절대 쓰지 말 것:");