using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
internal enum ExplorationScope
{
Localized,
TopicBased,
RepoWide,
OpenEnded,
/// 문서 생성 요청 — 탐색 단계를 건너뛰고 바로 document_plan/생성 도구 사용.
DirectCreation,
}
private sealed class ExplorationTrackingState
{
public ExplorationScope Scope { get; init; }
public int FolderMapCalls { get; set; }
public int TotalFilesRead { get; set; }
public int MultiReadFilesRead { get; set; }
public bool BroadScanDetected { get; set; }
public bool SelectiveHit { get; set; }
public bool CorrectiveHintInjected { get; set; }
/// 스킬 런타임이 allowed-tools를 명시했으면 true — 탐색 필터링을 건너뜀.
public bool SkillAllowedToolsActive { get; set; }
}
private static IReadOnlyCollection FilterExplorationToolsForCurrentIteration(
IReadOnlyCollection tools,
ExplorationTrackingState state,
string userQuery,
string? activeTab,
int totalToolCalls)
{
if (tools.Count == 0)
return tools;
// 스킬 런타임 정책으로 allowed-tools가 명시된 경우 탐색 필터링을 건너뜀
// — 스킬이 의도적으로 허용한 도구(folder_map 등)를 정책이 차단하면 안 됨
if (state.SkillAllowedToolsActive)
return tools;
// 문서 생성 모드: 생성 도구를 최우선, 탐색 도구를 뒤로 배치
if (state.Scope == ExplorationScope.DirectCreation)
{
var creationFirst = new List(tools.Count);
creationFirst.AddRange(tools.Where(IsDocumentCreationTool));
creationFirst.AddRange(tools.Where(t => !IsDocumentCreationTool(t)
&& !string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t.Name, "glob", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(t.Name, "grep", StringComparison.OrdinalIgnoreCase)));
// 탐색 도구는 마지막에 (필요 시에만 사용)
creationFirst.AddRange(tools.Where(t =>
string.Equals(t.Name, "glob", StringComparison.OrdinalIgnoreCase)
|| string.Equals(t.Name, "grep", StringComparison.OrdinalIgnoreCase)));
return creationFirst
.DistinctBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToList()
.AsReadOnly();
}
var allowFolderMap = ShouldAllowFolderMapForCurrentIteration(state, userQuery, activeTab, totalToolCalls);
var ordered = new List(tools.Count);
ordered.AddRange(tools.Where(IsSelectiveDiscoveryTool));
ordered.AddRange(tools.Where(t => !IsSelectiveDiscoveryTool(t) &&
(allowFolderMap || !string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase))));
if (allowFolderMap)
{
ordered.AddRange(tools.Where(t => string.Equals(t.Name, "folder_map", StringComparison.OrdinalIgnoreCase)));
}
return ordered
.DistinctBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToList()
.AsReadOnly();
}
private static bool IsDocumentCreationTool(IAgentTool tool)
{
return tool.Name is "document_plan" or "docx_create" or "html_create" or "excel_create"
or "markdown_create" or "csv_create" or "pptx_create" or "chart_create"
or "document_assemble" or "file_write";
}
private static bool ShouldAllowFolderMapForCurrentIteration(
ExplorationTrackingState state,
string userQuery,
string? activeTab,
int totalToolCalls)
{
// 문서 생성 모드에서는 folder_map 차단 — 탐색 없이 바로 생성
if (state.Scope == ExplorationScope.DirectCreation)
return false;
if (state.Scope is ExplorationScope.RepoWide or ExplorationScope.OpenEnded)
return true;
if (HasExplicitFolderIntent(userQuery))
return true;
if (string.Equals(activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)
&& IsExistingMaterialReferenceRequest(userQuery))
return true;
return totalToolCalls >= 2 && state.TotalFilesRead == 0 && state.FolderMapCalls == 0;
}
private static bool HasExplicitFolderIntent(string userQuery)
{
if (string.IsNullOrWhiteSpace(userQuery))
return false;
return ContainsAny(
userQuery,
"folder",
"directory",
"tree",
"structure",
"list files",
"workspace layout",
"work folder",
"project structure",
"폴더",
"디렉터리",
"폴더 구조",
"디렉터리 구조",
"파일 목록",
"작업 폴더",
"폴더 안",
"구조를 보여",
"구조 확인");
}
private static bool IsExistingMaterialReferenceRequest(string userQuery)
{
if (string.IsNullOrWhiteSpace(userQuery))
return false;
return ContainsAny(
userQuery,
"existing file",
"existing files",
"existing document",
"existing documents",
"reference files",
"reference docs",
"existing materials",
"기존 파일",
"기존 문서",
"기존 자료",
"참고 파일",
"참고 문서",
"폴더 내 자료",
"안의 자료",
"작업 폴더 파일");
}
private static bool IsSelectiveDiscoveryTool(IAgentTool tool)
{
return tool.Name is "glob" or "grep" or "file_read" or "document_read" or "multi_read" or "lsp_code_intel";
}
private static bool HasSemanticNavigationIntent(string userQuery)
{
if (string.IsNullOrWhiteSpace(userQuery))
return false;
return ContainsAny(
userQuery,
"definition",
"reference",
"references",
"implementation",
"implementations",
"caller",
"callers",
"callee",
"call hierarchy",
"symbol",
"symbols",
"interface",
"override",
"정의",
"참조",
"구현",
"호출부",
"호출 관계",
"심볼",
"인터페이스",
"오버라이드");
}
private static string BuildPreferredInitialToolSequence(
ExplorationTrackingState state,
TaskTypePolicy taskPolicy,
string? activeTab,
string userQuery)
{
// 문서 생성 의도가 감지되면 탐색 없이 바로 문서 계획/생성 도구 사용
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";
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase))
return "glob/grep -> document_read/file_read -> drafting tool";
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& HasSemanticNavigationIntent(userQuery))
return "lsp_code_intel -> targeted file_read -> file_edit/file_write -> build_run/test_loop as needed";
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase))
return "targeted file_read or grep/glob/lsp -> file_edit/file_write -> build_run/test_loop as needed";
if (state.Scope == ExplorationScope.TopicBased)
return "glob/grep -> targeted read -> next action";
return "glob/grep -> file_read/document_read -> next action";
}
private static ExplorationScope ClassifyExplorationScope(string userQuery, string? activeTab)
{
if (string.IsNullOrWhiteSpace(userQuery))
return ExplorationScope.OpenEnded;
var q = userQuery.Trim();
var lower = q.ToLowerInvariant();
// Cowork 탭에서 문서 생성 의도가 있으면 탐색을 건너뛰고 바로 생성 도구 사용
if (!string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
&& 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("전체 점검"))
return ExplorationScope.RepoWide;
if (q.Contains('.') || q.Contains('/') || q.Contains('\\') ||
lower.Contains("file ") || lower.Contains("class ") || lower.Contains("method ") ||
lower.Contains("function ") || lower.Contains("line ") || lower.Contains("bug") ||
lower.Contains("오류") || lower.Contains("버그") || lower.Contains("예외"))
return ExplorationScope.Localized;
if (lower.Contains("정리") || lower.Contains("요약") || lower.Contains("보고서") ||
lower.Contains("주제") || lower.Contains("관련") || lower.Contains("분석"))
return ExplorationScope.TopicBased;
return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
? ExplorationScope.Localized
: ExplorationScope.OpenEnded;
}
///
/// 사용자가 문서를 새로 생성/작성하려는 의도인지 판별합니다.
/// "보고서 작성해줘", "문서 만들어줘" 등 생성 동사가 포함된 경우 true.
///
private static bool HasDocumentCreationIntent(string lowerQuery)
{
// 생성 동사 키워드
var hasCreationVerb = ContainsAny(lowerQuery,
"작성해", "써줘", "써 줘", "만들어", "생성해", "작성 해",
"만들어줘", "만들어 줘", "생성해줘", "생성해 줘",
"write", "create", "draft", "generate", "compose",
"작성하", "작성을", "생성하", "생성을",
"리포트 써", "보고서 써", "문서 써",
"작성 부탁", "만들어 부탁");
if (!hasCreationVerb)
return false;
// 생성 대상이 문서/보고서/자료 등인지 확인
return ContainsAny(lowerQuery,
"보고서", "문서", "제안서", "리포트", "분석서", "기획서",
"report", "document", "proposal", "analysis",
"요약서", "발표자료", "ppt", "pptx", "docx", "xlsx", "excel", "word",
"표", "차트", "스프레드시트", "프레젠테이션",
"정리해", "정리 해");
}
private static bool HasCodeArtifactCreationIntent(string lowerQuery)
{
var hasCreationVerb = ContainsAny(
lowerQuery,
"만들", "생성", "작성", "create", "generate", "build", "write", "scaffold", "draft");
if (!hasCreationVerb)
return false;
return ContainsAny(
lowerQuery,
"html", "css", "javascript", "js", "typescript", "ts",
"web page", "webpage", "website", "landing page",
"웹페이지", "웹 페이지", "웹사이트", "페이지",
"file", "파일", "script", "스크립트", "component", "컴포넌트",
"template", "템플릿", "index.html", "app.js", "style.css");
}
private static void InjectExplorationScopeGuidance(List messages, ExplorationScope scope)
{
var guidance = scope switch
{
ExplorationScope.DirectCreation =>
"Exploration scope = direct-creation. The user wants to CREATE a new document/report/file. " +
"Do NOT search for existing files with glob/grep/folder_map — skip exploration entirely. " +
"Call document_plan first to outline the document structure, then immediately call the appropriate creation tool " +
"(docx_create, html_create, excel_create, markdown_create, etc.) to produce the actual file. " +
"The output MUST be a real file on disk, not a text response.",
ExplorationScope.Localized =>
"Exploration scope = localized. Start with lsp_code_intel when the request is about definitions/references/implementations/call hierarchy; otherwise use targeted file_read or grep/glob. Avoid folder_map unless the user explicitly asks for folder structure or file listing.",
ExplorationScope.TopicBased =>
"Exploration scope = topic-based. Identify candidate files by topic keywords first with glob/grep, then read only a small targeted set.",
ExplorationScope.RepoWide =>
"Exploration scope = repo-wide. Broad structure inspection is allowed when needed.",
_ =>
"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",
Content = guidance
});
}
private static int CountMultiReadPaths(string argsJson)
{
try
{
using var doc = JsonDocument.Parse(argsJson);
if (doc.RootElement.TryGetProperty("paths", out var pathsEl) && pathsEl.ValueKind == JsonValueKind.Array)
return pathsEl.GetArrayLength();
}
catch
{
}
return 0;
}
private static bool ShouldInjectExplorationCorrection(
ExplorationTrackingState state,
string toolName,
string argsJson)
{
// 문서 생성 모드: 탐색 도구가 1회라도 호출되면 즉시 교정
if (state.Scope == ExplorationScope.DirectCreation)
{
return string.Equals(toolName, "folder_map", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "glob", StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, "grep", StringComparison.OrdinalIgnoreCase)
|| state.TotalFilesRead >= 1;
}
if (state.Scope is ExplorationScope.RepoWide or ExplorationScope.OpenEnded)
return false;
if (state.FolderMapCalls >= 2)
return true;
if (state.MultiReadFilesRead >= 6 || state.TotalFilesRead >= 8)
return true;
if (string.Equals(toolName, "folder_map", StringComparison.OrdinalIgnoreCase))
{
try
{
using var doc = JsonDocument.Parse(argsJson);
var includeFiles = doc.RootElement.TryGetProperty("include_files", out var includeFilesEl) &&
includeFilesEl.ValueKind is JsonValueKind.True or JsonValueKind.False &&
includeFilesEl.GetBoolean();
var depth = doc.RootElement.TryGetProperty("depth", out var depthEl) && depthEl.ValueKind == JsonValueKind.Number
? depthEl.GetInt32()
: 2;
if (includeFiles || depth >= 3)
return true;
}
catch
{
}
}
return false;
}
private static void TrackExplorationToolUse(
ExplorationTrackingState state,
string toolName,
string argsJson)
{
if (string.Equals(toolName, "folder_map", StringComparison.OrdinalIgnoreCase))
{
state.FolderMapCalls++;
return;
}
if (string.Equals(toolName, "multi_read", StringComparison.OrdinalIgnoreCase))
{
var count = CountMultiReadPaths(argsJson);
state.MultiReadFilesRead += count;
state.TotalFilesRead += count;
if (count >= 6)
state.BroadScanDetected = true;
return;
}
if (string.Equals(toolName, "file_read", StringComparison.OrdinalIgnoreCase) ||
string.Equals(toolName, "document_read", StringComparison.OrdinalIgnoreCase))
{
state.TotalFilesRead++;
}
}
}