AX Agent 도구·스킬 정합성 재구성 및 실행 품질 보강
변경 목적: - 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
This commit is contained in:
409
src/AxCopilot/Services/Agent/WorkspaceContextGenerator.cs
Normal file
409
src/AxCopilot/Services/Agent/WorkspaceContextGenerator.cs
Normal file
@@ -0,0 +1,409 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 작업 폴더의 구조/기술스택/컨벤션을 분석하여 .ax-context.md를 자동 생성합니다.
|
||||
/// LLM 호출 없이 순수 파일 시스템 분석으로 동작합니다.
|
||||
/// </summary>
|
||||
internal static class WorkspaceContextGenerator
|
||||
{
|
||||
private const string ContextFileName = ".ax-context.md";
|
||||
private const int MaxDepth = 3;
|
||||
private const int MaxReadmeChars = 2000;
|
||||
private const int MaxContextChars = 4000;
|
||||
|
||||
private static readonly HashSet<string> SkipDirs = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".git", "node_modules", "bin", "obj", ".vs", "__pycache__", ".idea",
|
||||
".vscode", "dist", "build", "target", ".next", ".nuget", "packages",
|
||||
".ax", "coverage", ".mypy_cache", "venv", ".venv", "env",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// .ax-context.md가 없으면 생성합니다. 이미 있으면 기존 내용을 반환합니다.
|
||||
/// </summary>
|
||||
public static async Task<string?> EnsureContextAsync(string workFolder, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(workFolder) || !Directory.Exists(workFolder))
|
||||
return null;
|
||||
|
||||
var path = Path.Combine(workFolder, ContextFileName);
|
||||
if (File.Exists(path))
|
||||
return LoadContext(workFolder);
|
||||
|
||||
return await GenerateAsync(workFolder, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 강제 재생성합니다.
|
||||
/// </summary>
|
||||
public static async Task<string> GenerateAsync(string workFolder, CancellationToken ct = default)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("# Workspace Context (auto-generated)");
|
||||
sb.AppendLine($"Generated: {DateTime.Now:yyyy-MM-dd}");
|
||||
sb.AppendLine();
|
||||
|
||||
// 1. 프로젝트 기본 정보
|
||||
var buildSystem = DetectBuildSystem(workFolder);
|
||||
var extDist = GetExtensionDistribution(workFolder, ct);
|
||||
var primaryLang = extDist.FirstOrDefault();
|
||||
|
||||
sb.AppendLine("## Project");
|
||||
var projectName = Path.GetFileName(workFolder);
|
||||
sb.AppendLine($"- Name: {projectName}");
|
||||
if (buildSystem != null)
|
||||
sb.AppendLine($"- Build System: {buildSystem}");
|
||||
if (primaryLang.Key != null)
|
||||
sb.AppendLine($"- Primary Language: {GetLanguageName(primaryLang.Key)} ({primaryLang.Key}: {primaryLang.Value} files)");
|
||||
|
||||
// Git 정보
|
||||
var gitInfo = await GetGitInfoAsync(workFolder, ct).ConfigureAwait(false);
|
||||
if (gitInfo.Branch != null)
|
||||
sb.AppendLine($"- Git Branch: {gitInfo.Branch}");
|
||||
if (gitInfo.Remote != null)
|
||||
sb.AppendLine($"- Git Remote: {gitInfo.Remote}");
|
||||
sb.AppendLine();
|
||||
|
||||
// 2. 디렉토리 구조
|
||||
sb.AppendLine("## Structure");
|
||||
var tree = BuildDirectoryTree(workFolder, MaxDepth);
|
||||
foreach (var line in tree.Take(30)) // 최대 30줄
|
||||
sb.AppendLine(line);
|
||||
sb.AppendLine();
|
||||
|
||||
// 3. 확장자 분포
|
||||
if (extDist.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## File Distribution");
|
||||
sb.AppendLine(string.Join(", ", extDist.Take(10).Select(kv => $"{kv.Key}: {kv.Value}")));
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// 4. 기존 컨텍스트 파일 감지
|
||||
var contextFiles = DetectContextFiles(workFolder);
|
||||
if (contextFiles.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Existing Context Files");
|
||||
foreach (var cf in contextFiles)
|
||||
sb.AppendLine($"- {cf}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// 5. README 요약
|
||||
var readmeSummary = ExtractReadmeSummary(workFolder);
|
||||
if (readmeSummary != null)
|
||||
{
|
||||
sb.AppendLine("## README Summary");
|
||||
sb.AppendLine(readmeSummary);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
var content = sb.ToString().TrimEnd();
|
||||
|
||||
// 파일 저장
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(workFolder, ContextFileName);
|
||||
await File.WriteAllTextAsync(path, content, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 저장 실패는 무시 — 읽기 전용 폴더 등
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 기존 .ax-context.md를 읽습니다. 없으면 null.
|
||||
/// </summary>
|
||||
public static string? LoadContext(string? workFolder)
|
||||
{
|
||||
if (string.IsNullOrEmpty(workFolder)) return null;
|
||||
var path = Path.Combine(workFolder, ContextFileName);
|
||||
if (!File.Exists(path)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(path);
|
||||
return content.Length > MaxContextChars
|
||||
? content[..MaxContextChars] + "\n...(truncated)"
|
||||
: content;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// 분석 로직
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
private static string? DetectBuildSystem(string folder)
|
||||
{
|
||||
var checks = new (string Pattern, string Name)[]
|
||||
{
|
||||
("*.sln", ".NET (Solution)"),
|
||||
("*.csproj", ".NET"),
|
||||
("package.json", "Node.js"),
|
||||
("Cargo.toml", "Rust"),
|
||||
("go.mod", "Go"),
|
||||
("pom.xml", "Java (Maven)"),
|
||||
("build.gradle", "Java (Gradle)"),
|
||||
("pyproject.toml", "Python"),
|
||||
("requirements.txt", "Python"),
|
||||
("Makefile", "Make"),
|
||||
("CMakeLists.txt", "CMake"),
|
||||
};
|
||||
|
||||
foreach (var (pattern, name) in checks)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.GetFiles(folder, pattern, SearchOption.TopDirectoryOnly).Length > 0)
|
||||
return name;
|
||||
}
|
||||
catch { /* 무시 */ }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<KeyValuePair<string, int>> GetExtensionDistribution(
|
||||
string folder, CancellationToken ct)
|
||||
{
|
||||
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(folder, "*", new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
MaxRecursionDepth = 5,
|
||||
}))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// 경로 세그먼트 단위로 SkipDirs 검사 (정확한 디렉토리명 매칭)
|
||||
var dir = Path.GetDirectoryName(file) ?? "";
|
||||
if (ShouldSkipPath(dir))
|
||||
continue;
|
||||
|
||||
var ext = Path.GetExtension(file);
|
||||
if (string.IsNullOrEmpty(ext) || ext.Length > 8) continue;
|
||||
|
||||
counts.TryGetValue(ext, out var count);
|
||||
counts[ext] = count + 1;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch { /* 무시 */ }
|
||||
|
||||
return counts.OrderByDescending(kv => kv.Value).ToList();
|
||||
}
|
||||
|
||||
private static List<string> BuildDirectoryTree(string root, int maxDepth)
|
||||
{
|
||||
var result = new List<string>();
|
||||
BuildTreeRecursive(root, root, 0, maxDepth, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void BuildTreeRecursive(string root, string current, int depth, int maxDepth, List<string> result)
|
||||
{
|
||||
if (depth >= maxDepth || result.Count >= 30) return;
|
||||
|
||||
IEnumerable<string> dirs;
|
||||
try { dirs = Directory.GetDirectories(current); }
|
||||
catch { return; }
|
||||
|
||||
foreach (var dir in dirs.OrderBy(d => d))
|
||||
{
|
||||
if (result.Count >= 30) break;
|
||||
|
||||
var name = Path.GetFileName(dir);
|
||||
if (SkipDirs.Contains(name)) continue;
|
||||
|
||||
// 심링크/junction 무한루프 방지: 속성 검사
|
||||
try
|
||||
{
|
||||
var attrs = File.GetAttributes(dir);
|
||||
if (attrs.HasFlag(FileAttributes.ReparsePoint))
|
||||
continue;
|
||||
}
|
||||
catch { continue; }
|
||||
|
||||
// TopDirectoryOnly로 제한하여 대규모 디렉토리 탐색 방지
|
||||
int fileCount;
|
||||
try
|
||||
{
|
||||
fileCount = Directory.EnumerateFiles(dir, "*", new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
MaxRecursionDepth = 3,
|
||||
}).Take(10_000).Count(); // 최대 10K개만 카운트
|
||||
}
|
||||
catch { fileCount = 0; }
|
||||
|
||||
var indent = new string(' ', depth * 2);
|
||||
var relativePath = Path.GetRelativePath(root, dir).Replace('\\', '/');
|
||||
result.Add($"{indent}{relativePath}/ ({fileCount}{(fileCount >= 10_000 ? "+" : "")} files)");
|
||||
|
||||
BuildTreeRecursive(root, dir, depth + 1, maxDepth, result);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractReadmeSummary(string folder)
|
||||
{
|
||||
var names = new[] { "README.md", "readme.md", "README", "README.txt" };
|
||||
foreach (var name in names)
|
||||
{
|
||||
var path = Path.Combine(folder, name);
|
||||
if (!File.Exists(path)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var text = File.ReadAllText(path);
|
||||
if (text.Length > MaxReadmeChars)
|
||||
text = text[..MaxReadmeChars];
|
||||
|
||||
// 첫 번째 단락 추출 (제목 제외)
|
||||
var lines = text.Split('\n');
|
||||
var paragraphLines = new List<string>();
|
||||
var foundContent = false;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith('#') && !foundContent) continue; // 제목 건너뛰기
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
if (foundContent && paragraphLines.Count > 0) break;
|
||||
continue;
|
||||
}
|
||||
foundContent = true;
|
||||
paragraphLines.Add(trimmed);
|
||||
}
|
||||
|
||||
if (paragraphLines.Count > 0)
|
||||
return string.Join(" ", paragraphLines);
|
||||
}
|
||||
catch { /* 무시 */ }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string> DetectContextFiles(string folder)
|
||||
{
|
||||
var files = new List<string>();
|
||||
var names = new[] { "AGENTS.md", "AX.md", "CLAUDE.md", ".clinerules", ".ax-rules" };
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (File.Exists(Path.Combine(folder, name)))
|
||||
files.Add(name);
|
||||
}
|
||||
|
||||
var axDir = Path.Combine(folder, ".ax");
|
||||
if (Directory.Exists(axDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
var rulesDir = Path.Combine(axDir, "rules");
|
||||
if (Directory.Exists(rulesDir))
|
||||
{
|
||||
var ruleFiles = Directory.GetFiles(rulesDir, "*.md");
|
||||
if (ruleFiles.Length > 0)
|
||||
files.Add($".ax/rules/ ({ruleFiles.Length} files)");
|
||||
}
|
||||
}
|
||||
catch { /* 무시 */ }
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private static async Task<(string? Branch, string? Remote)> GetGitInfoAsync(
|
||||
string folder, CancellationToken ct)
|
||||
{
|
||||
if (!Directory.Exists(Path.Combine(folder, ".git")))
|
||||
return (null, null);
|
||||
|
||||
string? branch = null;
|
||||
string? remote = null;
|
||||
|
||||
try
|
||||
{
|
||||
branch = await RunGitAsync(folder, "rev-parse --abbrev-ref HEAD", ct).ConfigureAwait(false);
|
||||
remote = await RunGitAsync(folder, "remote get-url origin", ct).ConfigureAwait(false);
|
||||
}
|
||||
catch { /* Git 없거나 실패 */ }
|
||||
|
||||
return (branch?.Trim(), remote?.Trim());
|
||||
}
|
||||
|
||||
private static async Task<string?> RunGitAsync(string folder, string args, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = new Process();
|
||||
process.StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "git",
|
||||
Arguments = args,
|
||||
WorkingDirectory = folder,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
process.Start();
|
||||
var output = await process.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false);
|
||||
await process.WaitForExitAsync(ct).ConfigureAwait(false);
|
||||
return process.ExitCode == 0 ? output.Trim() : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>경로의 각 디렉토리 세그먼트가 SkipDirs에 해당하는지 검사.</summary>
|
||||
private static bool ShouldSkipPath(string dirPath)
|
||||
{
|
||||
var span = dirPath.AsSpan();
|
||||
while (span.Length > 0)
|
||||
{
|
||||
var sepIdx = span.IndexOfAny(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var segment = sepIdx >= 0 ? span[..sepIdx] : span;
|
||||
if (segment.Length > 0 && SkipDirs.Contains(segment.ToString()))
|
||||
return true;
|
||||
if (sepIdx < 0) break;
|
||||
span = span[(sepIdx + 1)..];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GetLanguageName(string ext) => ext.ToLowerInvariant() switch
|
||||
{
|
||||
".cs" => "C#",
|
||||
".ts" or ".tsx" => "TypeScript",
|
||||
".js" or ".jsx" => "JavaScript",
|
||||
".py" => "Python",
|
||||
".rs" => "Rust",
|
||||
".go" => "Go",
|
||||
".java" => "Java",
|
||||
".cpp" or ".cc" or ".cxx" => "C++",
|
||||
".c" => "C",
|
||||
".rb" => "Ruby",
|
||||
".php" => "PHP",
|
||||
".swift" => "Swift",
|
||||
".kt" => "Kotlin",
|
||||
".xaml" => "XAML",
|
||||
".html" or ".htm" => "HTML",
|
||||
".css" => "CSS",
|
||||
_ => ext.TrimStart('.').ToUpperInvariant(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user