?? ? ? ?? ?? ?? ??? ???? 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:
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 = "";
|
||||
|
||||
Reference in New Issue
Block a user