변경 목적: - AX Agent의 도구 이름, 내부 설정, 스킬 정책, 실행 루프 사이의 불일치를 줄이고 전체 동작 품질을 높인다. - claw-code 수준의 일관된 동작 품질을 참고하되 AX 구조에 맞는 고유한 카탈로그·정규화 레이어로 재구성한다. 핵심 수정사항: - 도구 canonical id, legacy alias, 탭 노출, 설정 카테고리, read-only 분류를 중앙 카탈로그로 통합했다. - ToolRegistry, AgentLoopService, 병렬 실행 분류, 권한 처리, 훅 처리, 스킬 allowed-tools 해석이 같은 이름 체계를 사용하도록 정리했다. - Agent 설정/일반 설정/도움말의 도구 카드와 훅 편집기, 스킬 설명을 현재 런타임 구조에 맞게 갱신했다. - 컨텍스트 압축, intent gate, spawn agents, session learning, model prompt adapter, workspace context 관련 변경과 테스트 추가를 함께 반영했다. - 문서 이력과 비교/로드맵 문서를 최신 상태로 갱신했다. 검증 결과: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_toolcat\ -p:IntermediateOutputPath=obj\verify_toolcat\ : 경고 0 / 오류 0 - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\verify_toolcat_tests\ -p:IntermediateOutputPath=obj\verify_toolcat_tests\ : 통과 8
401 lines
16 KiB
C#
401 lines
16 KiB
C#
using System.Text.Json;
|
|
using AxCopilot.Models;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
public partial class AgentLoopService
|
|
{
|
|
internal enum ExplorationScope
|
|
{
|
|
Localized,
|
|
TopicBased,
|
|
RepoWide,
|
|
OpenEnded,
|
|
/// <summary>문서 생성 요청 — 탐색 단계를 건너뛰고 바로 document_plan/생성 도구 사용.</summary>
|
|
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; }
|
|
/// <summary>스킬 런타임이 allowed-tools를 명시했으면 true — 탐색 필터링을 건너뜀.</summary>
|
|
public bool SkillAllowedToolsActive { get; set; }
|
|
}
|
|
|
|
private static IReadOnlyCollection<IAgentTool> FilterExplorationToolsForCurrentIteration(
|
|
IReadOnlyCollection<IAgentTool> 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<IAgentTool>(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<IAgentTool>(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)
|
|
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 (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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 사용자가 문서를 새로 생성/작성하려는 의도인지 판별합니다.
|
|
/// "보고서 작성해줘", "문서 만들어줘" 등 생성 동사가 포함된 경우 true.
|
|
/// </summary>
|
|
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 void InjectExplorationScopeGuidance(List<ChatMessage> 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."
|
|
};
|
|
|
|
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++;
|
|
}
|
|
}
|
|
}
|