모델 프로파일 기반 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:
2026-04-08 13:41:57 +09:00
parent b391dfdfb3
commit a2c952879d
552 changed files with 8094 additions and 13595 deletions

View File

@@ -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";
}
}