모델 프로파일 기반 Cowork/Code 루프와 진행 UX 고도화 반영
- 등록 모델 실행 프로파일을 검증 게이트, 문서 fallback, post-tool verification까지 확장 적용 - Cowork/Code 진행 카드에 계획/도구/검증/압축/폴백/재시도 단계 메타를 추가해 대기 상태 가시성 강화 - OpenAI/vLLM tool 요청에 병렬 도구 호출 힌트를 추가하고 회귀 프롬프트 문서를 프로파일 기준으로 전면 정리 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
@@ -7,22 +9,30 @@ namespace AxCopilot.Services.Agent;
|
||||
public class GlobTool : IAgentTool
|
||||
{
|
||||
public string Name => "glob";
|
||||
public string Description => "Find files matching a glob pattern (e.g. '**/*.cs', 'src/**/*.json'). Returns matching file paths.";
|
||||
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 (e.g. '**/*.cs', '*.txt')" },
|
||||
["path"] = new() { Type = "string", Description = "Directory to search in. Optional, defaults to work folder." },
|
||||
["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").GetString() ?? "";
|
||||
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
||||
var pattern = args.GetProperty("pattern").GetString() ?? "";
|
||||
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
||||
var sortBy = args.TryGetProperty("sort_by", out var sb) ? sb.GetString() ?? "name" : "name";
|
||||
var maxResults = args.TryGetProperty("max_results", out var mr) ? Math.Clamp(mr.GetInt32(), 1, 2000) : 200;
|
||||
var includeHidden = args.TryGetProperty("include_hidden", out var ih) && ih.GetBoolean();
|
||||
var excludePattern = args.TryGetProperty("exclude_pattern", out var ep) ? ep.GetString() ?? "" : "";
|
||||
|
||||
var baseDir = string.IsNullOrEmpty(searchPath)
|
||||
? context.WorkFolder
|
||||
@@ -36,22 +46,89 @@ public class GlobTool : IAgentTool
|
||||
|
||||
try
|
||||
{
|
||||
// glob 패턴을 Directory.EnumerateFiles용으로 변환
|
||||
var searchPattern = ExtractSearchPattern(pattern);
|
||||
var recursive = pattern.Contains("**") || pattern.Contains('/') || pattern.Contains('\\');
|
||||
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;
|
||||
|
||||
var files = Directory.EnumerateFiles(baseDir, searchPattern, option)
|
||||
.Where(f => context.IsPathAllowed(f))
|
||||
.OrderBy(f => f)
|
||||
.Take(200)
|
||||
// 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}'에 일치하는 파일이 없습니다."));
|
||||
|
||||
var result = string.Join("\n", files.Select(f => Path.GetRelativePath(baseDir, f)));
|
||||
return Task.FromResult(ToolResult.Ok($"{files.Count}개 파일 발견:\n{result}"));
|
||||
// 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)
|
||||
{
|
||||
@@ -59,11 +136,81 @@ public class GlobTool : IAgentTool
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractSearchPattern(string globPattern)
|
||||
/// <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)
|
||||
{
|
||||
// **/*.cs → *.cs, src/**/*.json → *.json
|
||||
var parts = globPattern.Replace('/', '\\').Split('\\');
|
||||
var last = parts[^1];
|
||||
return string.IsNullOrEmpty(last) || last == "**" ? "*" : last;
|
||||
// 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";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user