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) 기준 개발 이력을 반영함
217 lines
9.1 KiB
C#
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";
|
|
}
|
|
}
|