Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/GlobTool.cs
lacvet 33c1db4dae
Some checks failed
Release Gate / gate (push) Has been cancelled
에이전트 선택적 탐색 구조 개선과 경고 정리 반영
- claude-code 선택적 탐색 흐름을 참고해 Cowork/Code 시스템 프롬프트에서 folder_map 상시 선행 지시를 완화하고 glob/grep 기반 좁은 탐색을 우선하도록 조정함

- FolderMapTool 기본 depth를 2로, include_files 기본값을 false로 낮추고 MultiReadTool 최대 파일 수를 8개로 줄여 초기 과탐색 폭을 보수적으로 조정함

- AgentLoopExplorationPolicy partial을 추가해 탐색 범위 분류, broad-scan corrective hint, exploration_breadth 성능 로그를 연결함

- AgentLoopService에 탐색 범위 가이드 주입과 실행 중 탐색 폭 추적을 추가하고, 좁은 질문에서 반복적인 folder_map/대량 multi_read를 교정하도록 정리함

- DocxToHtmlConverter nullable 경고를 수정해 Release 빌드 경고 0 / 오류 0 기준을 다시 충족함

- README와 docs/DEVELOPMENT.md에 2026-04-09 10:36 (KST) 기준 개발 이력을 반영함
2026-04-09 14:27:59 +09:00

217 lines
9.1 KiB
C#

using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>파일 패턴 검색 도구. glob 패턴으로 파일 목록을 반환합니다.</summary>
public class GlobTool : IAgentTool
{
public string Name => "glob";
public string Description => "Find files matching a glob pattern (e.g. '**/*.cs', 'src/**/*.json', '*.txt'). Returns matching file paths relative to the search directory.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["pattern"] = new() { Type = "string", Description = "Glob pattern to match files. Supports ** (recursive), * (any chars), ? (single char). E.g. '**/*.cs', 'src/models/*.json', '*.txt'." },
["path"] = new() { Type = "string", Description = "Directory to search in. Optional, defaults to work folder." },
["sort_by"] = new() { Type = "string", Description = "'name' (default), 'size', or 'modified'. When size/modified, shows that info next to each file." },
["max_results"] = new() { Type = "integer", Description = "Maximum number of results to return. Default 200, max 2000." },
["include_hidden"] = new() { Type = "boolean", Description = "Include hidden files/directories (starting with '.'). Default false." },
["exclude_pattern"] = new() { Type = "string", Description = "Glob pattern for files to exclude (e.g. '*.min.js', '*.g.cs'). Matched against file name only." },
},
Required = ["pattern"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var pattern = args.GetProperty("pattern").SafeGetString() ?? "";
var searchPath = args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : "";
var sortBy = args.SafeTryGetProperty("sort_by", out var sb) ? sb.SafeGetString() ?? "name" : "name";
var maxResults = args.SafeTryGetProperty("max_results", out var mr) ? Math.Clamp(mr.GetInt32(), 1, 2000) : 200;
var includeHidden = args.SafeTryGetProperty("include_hidden", out var ih) && ih.GetBoolean();
var excludePattern = args.SafeTryGetProperty("exclude_pattern", out var ep) ? ep.SafeGetString() ?? "" : "";
var baseDir = string.IsNullOrEmpty(searchPath)
? context.WorkFolder
: FileReadTool.ResolvePath(searchPath, context.WorkFolder);
if (string.IsNullOrEmpty(baseDir) || !Directory.Exists(baseDir))
return Task.FromResult(ToolResult.Fail($"디렉토리가 존재하지 않습니다: {baseDir}"));
if (!context.IsPathAllowed(baseDir))
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {baseDir}"));
try
{
var (searchDir, filePattern, recursive) = DecomposePattern(pattern, baseDir);
if (!Directory.Exists(searchDir))
return Task.FromResult(ToolResult.Ok($"패턴 '{pattern}'에 일치하는 파일이 없습니다. (디렉토리 없음: {Path.GetRelativePath(baseDir, searchDir)})"));
var option = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
// Build a regex from the file-name portion of the glob for exact matching
var fileNameRegex = GlobSegmentToRegex(filePattern);
// Build exclude regex if provided (matched against filename only)
Regex? excludeRegex = string.IsNullOrEmpty(excludePattern)
? null
: new Regex(GlobSegmentToRegex(excludePattern), RegexOptions.IgnoreCase | RegexOptions.Compiled);
var files = Directory.EnumerateFiles(searchDir, "*", option)
.Where(f =>
{
if (!context.IsPathAllowed(f)) return false;
var fileName = Path.GetFileName(f);
// Hidden file/dir check
if (!includeHidden)
{
// Check every path segment from searchDir downward
var rel = Path.GetRelativePath(searchDir, f);
if (rel.Split(Path.DirectorySeparatorChar)
.Any(seg => seg.StartsWith('.')))
return false;
}
// File-name pattern match
if (!Regex.IsMatch(fileName, fileNameRegex, RegexOptions.IgnoreCase))
return false;
// Exclude pattern
if (excludeRegex != null && excludeRegex.IsMatch(fileName))
return false;
return true;
})
.ToList();
if (files.Count == 0)
return Task.FromResult(ToolResult.Ok($"패턴 '{pattern}'에 일치하는 파일이 없습니다."));
// Sort
IEnumerable<string> sorted = sortBy switch
{
"size" => files.OrderBy(f => new FileInfo(f).Length),
"modified" => files.OrderByDescending(f => new FileInfo(f).LastWriteTime),
_ => files.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
};
var taken = sorted.Take(maxResults).ToList();
var truncated = files.Count > maxResults;
var sb2 = new StringBuilder();
foreach (var f in taken)
{
var rel = Path.GetRelativePath(baseDir, f);
if (sortBy == "size")
{
var size = new FileInfo(f).Length;
sb2.AppendLine($"{rel} ({FormatSize(size)})");
}
else if (sortBy == "modified")
{
var ts = new FileInfo(f).LastWriteTime;
sb2.AppendLine($"{rel} ({ts:yyyy-MM-dd HH:mm:ss})");
}
else
{
sb2.AppendLine(rel);
}
}
var summary = truncated
? $"{taken.Count}개 파일 표시 (전체 {files.Count}개, 제한 {maxResults}개):"
: $"{taken.Count}개 파일 발견:";
return Task.FromResult(ToolResult.Ok($"{summary}\n{sb2.ToString().TrimEnd()}"));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"검색 실패: {ex.Message}"));
}
}
/// <summary>
/// Decomposes a glob pattern into (searchDirectory, fileNamePattern, recursive).
/// Examples:
/// "**/*.cs" → (baseDir, "*.cs", true)
/// "*.txt" → (baseDir, "*.txt", false)
/// "src/**/*.json" → (baseDir/src, "*.json", true)
/// "src/models/*.cs" → (baseDir/src/models, "*.cs", false)
/// "src/models/Foo.cs" → (baseDir/src/models, "Foo.cs", false)
/// </summary>
private static (string searchDir, string filePattern, bool recursive) DecomposePattern(
string pattern, string baseDir)
{
// Normalise separators
var normalised = pattern.Replace('\\', '/');
var segments = normalised.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
return (baseDir, "*", true);
// The last segment is always the file-name pattern (may contain * or ?)
var filePattern = segments[^1];
var dirSegments = segments[..^1]; // everything before the last segment
bool recursive = false;
var pathParts = new List<string>();
foreach (var seg in dirSegments)
{
if (seg == "**")
{
recursive = true;
// Do not add ** itself to the path; it means "any depth"
}
else
{
pathParts.Add(seg);
}
}
// If the file pattern itself is **, treat as recursive wildcard
if (filePattern == "**")
{
recursive = true;
filePattern = "*";
}
var searchDir = pathParts.Count > 0
? Path.Combine(new[] { baseDir }.Concat(pathParts).ToArray())
: baseDir;
return (searchDir, filePattern, recursive);
}
/// <summary>Converts a simple glob segment (*, ?, literals) to a full-match regex string.</summary>
private static string GlobSegmentToRegex(string glob)
{
var sb = new StringBuilder("^");
foreach (var ch in glob)
{
switch (ch)
{
case '*': sb.Append(".*"); break;
case '?': sb.Append('.'); break;
case '.': sb.Append("\\."); break;
default: sb.Append(Regex.Escape(ch.ToString())); break;
}
}
sb.Append('$');
return sb.ToString();
}
private static string FormatSize(long bytes)
{
if (bytes >= 1_048_576) return $"{bytes / 1_048_576.0:F1} MB";
if (bytes >= 1_024) return $"{bytes / 1_024.0:F1} KB";
return $"{bytes} B";
}
}