모델 프로파일 기반 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
+201 -35
View File
@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using System.Text;
using System.Text.Json;
@@ -13,7 +13,8 @@ public class FolderMapTool : IAgentTool
public string Name => "folder_map";
public string Description =>
"Generate a directory tree map of the work folder or a specified subfolder. " +
"Shows folders and files in a tree structure. Use this to understand the project layout before reading or editing files.";
"Shows folders and files in a tree structure. Use this to understand the project layout before reading or editing files. " +
"Supports sorting, size filtering, date filtering, and multi-extension filtering.";
public ToolParameterSchema Parameters => new()
{
@@ -22,7 +23,17 @@ public class FolderMapTool : IAgentTool
["path"] = new() { Type = "string", Description = "Subdirectory to map. Optional, defaults to work folder root." },
["depth"] = new() { Type = "integer", Description = "Maximum depth to traverse (1-10). Default: 3." },
["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: true." },
["pattern"] = new() { Type = "string", Description = "File extension filter (e.g. '.cs', '.py'). Optional, shows all files if omitted." },
["pattern"] = new() { Type = "string", Description = "Single file extension filter (e.g. '.cs', '.py'). Optional. Use 'extensions' for multiple extensions." },
["extensions"] = new()
{
Type = "array",
Description = "Filter by multiple extensions, e.g. [\".cs\", \".json\"]. Takes precedence over 'pattern' if both are provided.",
Items = new ToolProperty { Type = "string" },
},
["sort_by"] = new() { Type = "string", Description = "Sort files/dirs within each level: 'name' (default), 'size' (descending), 'modified' (newest first)." },
["show_dir_sizes"] = new() { Type = "boolean", Description = "If true, show the total size of each directory in parentheses. Default: false." },
["modified_after"] = new() { Type = "string", Description = "ISO date string (e.g. '2024-01-01'). Only show files modified after this date." },
["max_file_size"] = new() { Type = "string", Description = "Only show files smaller than this size, e.g. '1MB', '500KB', '2048B'." },
},
Required = []
};
@@ -40,14 +51,20 @@ public class FolderMapTool : IAgentTool
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
// ── path ──────────────────────────────────────────────────────────
var subPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
// ── depth ─────────────────────────────────────────────────────────
var depth = 3;
if (args.TryGetProperty("depth", out var d))
{
if (d.ValueKind == JsonValueKind.Number) depth = d.GetInt32();
else if (d.ValueKind == JsonValueKind.String && int.TryParse(d.GetString(), out var dv)) depth = dv;
}
var depthStr = depth.ToString();
if (depth < 1) depth = 1;
var maxDepth = Math.Min(depth, 10);
// ── include_files ─────────────────────────────────────────────────
var includeFiles = true;
if (args.TryGetProperty("include_files", out var inc))
{
@@ -56,12 +73,51 @@ public class FolderMapTool : IAgentTool
else
includeFiles = !string.Equals(inc.GetString(), "false", StringComparison.OrdinalIgnoreCase);
}
var extFilter = args.TryGetProperty("pattern", out var pat) ? pat.GetString() ?? "" : "";
if (!int.TryParse(depthStr, out var maxDepth) || maxDepth < 1)
maxDepth = 3;
maxDepth = Math.Min(maxDepth, 10);
// ── extensions / pattern ──────────────────────────────────────────
HashSet<string>? extSet = null;
if (args.TryGetProperty("extensions", out var extsEl) && extsEl.ValueKind == JsonValueKind.Array)
{
var list = extsEl.EnumerateArray()
.Select(e => e.GetString() ?? "")
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s.StartsWith('.') ? s : "." + s)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (list.Count > 0) extSet = list;
}
// Fall back to single pattern if extensions not provided
string extFilter = "";
if (extSet == null)
extFilter = args.TryGetProperty("pattern", out var pat) ? pat.GetString() ?? "" : "";
// ── sort_by ───────────────────────────────────────────────────────
var sortBy = args.TryGetProperty("sort_by", out var sb2) ? sb2.GetString() ?? "name" : "name";
if (sortBy != "size" && sortBy != "modified") sortBy = "name";
// ── show_dir_sizes ────────────────────────────────────────────────
var showDirSizes = false;
if (args.TryGetProperty("show_dir_sizes", out var sds))
{
if (sds.ValueKind == JsonValueKind.True || sds.ValueKind == JsonValueKind.False)
showDirSizes = sds.GetBoolean();
else
showDirSizes = string.Equals(sds.GetString(), "true", StringComparison.OrdinalIgnoreCase);
}
// ── modified_after ────────────────────────────────────────────────
DateTime? modifiedAfter = null;
if (args.TryGetProperty("modified_after", out var maEl) && maEl.ValueKind == JsonValueKind.String)
{
if (DateTime.TryParse(maEl.GetString(), out var mdt))
modifiedAfter = mdt;
}
// ── max_file_size ─────────────────────────────────────────────────
long? maxFileSizeBytes = null;
if (args.TryGetProperty("max_file_size", out var mfsEl) && mfsEl.ValueKind == JsonValueKind.String)
maxFileSizeBytes = ParseSizeString(mfsEl.GetString() ?? "");
// ── resolve base directory ────────────────────────────────────────
var baseDir = string.IsNullOrEmpty(subPath)
? context.WorkFolder
: FileReadTool.ResolvePath(subPath, context.WorkFolder);
@@ -74,18 +130,31 @@ public class FolderMapTool : IAgentTool
try
{
var options = new TreeOptions(maxDepth, includeFiles, extFilter, extSet,
sortBy, showDirSizes, modifiedAfter, maxFileSizeBytes);
var sb = new StringBuilder();
var dirName = Path.GetFileName(baseDir);
if (string.IsNullOrEmpty(dirName)) dirName = baseDir;
sb.AppendLine($"{dirName}/");
long rootTotalSize = 0;
sb.Append($"{dirName}/");
int entryCount = 0;
BuildTree(sb, baseDir, "", 0, maxDepth, includeFiles, extFilter, context, ref entryCount);
int totalFiles = 0;
int totalDirs = 0;
BuildTree(sb, baseDir, "", 0, options, context,
ref entryCount, ref totalFiles, ref totalDirs, ref rootTotalSize);
if (showDirSizes)
sb.Insert(sb.ToString().IndexOf('/') + 1, $" ({FormatSize(rootTotalSize)})");
sb.AppendLine(); // newline after root
if (entryCount >= MaxEntries)
sb.AppendLine($"\n... ({MaxEntries}개 항목 제한 도달, depth 또는 pattern을 조정하세요)");
sb.AppendLine($"\n... ({MaxEntries} entry limit reached; adjust depth or filters)");
var summary = $"폴더 맵 생성 완료 ({entryCount}개 항목, 깊이 {maxDepth})";
var summary = $"Folder map complete — {totalFiles} files, {totalDirs} dirs, {FormatSize(rootTotalSize)} total (depth {maxDepth})";
return Task.FromResult(ToolResult.Ok($"{summary}\n\n{sb}"));
}
catch (Exception ex)
@@ -94,44 +163,58 @@ public class FolderMapTool : IAgentTool
}
}
private static void BuildTree(
StringBuilder sb, string dir, string prefix, int currentDepth, int maxDepth,
bool includeFiles, string extFilter, AgentContext context, ref int entryCount)
{
if (currentDepth >= maxDepth || entryCount >= MaxEntries) return;
// ─── Tree builder ────────────────────────────────────────────────────
// 하위 디렉토리
private static void BuildTree(
StringBuilder sb, string dir, string prefix, int currentDepth,
TreeOptions opts, AgentContext context,
ref int entryCount, ref int totalFiles, ref int totalDirs, ref long accumSize)
{
if (currentDepth >= opts.MaxDepth || entryCount >= MaxEntries) return;
// ── Collect subdirectories ────────────────────────────────────────
List<DirectoryInfo> subDirs;
try
{
subDirs = new DirectoryInfo(dir).GetDirectories()
.Where(d => !d.Attributes.HasFlag(FileAttributes.Hidden)
&& !IgnoredDirs.Contains(d.Name))
.OrderBy(d => d.Name)
.ToList();
}
catch { return; } // 접근 불가 디렉토리 무시
catch { return; }
// 하위 파일
// ── Collect files ─────────────────────────────────────────────────
List<FileInfo> files = [];
if (includeFiles)
if (opts.IncludeFiles)
{
try
{
files = new DirectoryInfo(dir).GetFiles()
.Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden)
&& (string.IsNullOrEmpty(extFilter)
|| f.Extension.Equals(extFilter, StringComparison.OrdinalIgnoreCase)))
.OrderBy(f => f.Name)
.Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden))
.Where(f => MatchesExtension(f, opts.ExtFilter, opts.ExtSet))
.Where(f => opts.ModifiedAfter == null || f.LastWriteTime > opts.ModifiedAfter.Value)
.Where(f => opts.MaxFileSizeBytes == null || f.Length <= opts.MaxFileSizeBytes.Value)
.ToList();
}
catch { /* ignore */ }
}
// ── Sort ──────────────────────────────────────────────────────────
subDirs = opts.SortBy == "modified"
? subDirs.OrderByDescending(d => d.LastWriteTime).ToList()
: subDirs.OrderBy(d => d.Name).ToList();
files = opts.SortBy switch
{
"size" => files.OrderByDescending(f => f.Length).ToList(),
"modified" => files.OrderByDescending(f => f.LastWriteTime).ToList(),
_ => files.OrderBy(f => f.Name).ToList(),
};
var totalItems = subDirs.Count + files.Count;
var index = 0;
// 디렉토리 출력
// ── Render subdirectories ─────────────────────────────────────────
foreach (var sub in subDirs)
{
if (entryCount >= MaxEntries) break;
@@ -140,15 +223,41 @@ public class FolderMapTool : IAgentTool
var connector = isLast ? "└── " : "├── ";
var childPrefix = isLast ? " " : "│ ";
sb.AppendLine($"{prefix}{connector}{sub.Name}/");
entryCount++;
long subSize = 0;
int subFiles = 0, subDirsCount = 0;
_ = subFiles; _ = subDirsCount; // suppress unused warnings (reserved for future use)
if (context.IsPathAllowed(sub.FullName))
BuildTree(sb, sub.FullName, prefix + childPrefix, currentDepth + 1, maxDepth,
includeFiles, extFilter, context, ref entryCount);
{
// We always recurse to gather sizes; count only when rendered
if (opts.ShowDirSizes)
{
// Pre-compute directory size (best-effort, no error propagation)
try { subSize = ComputeDirSize(sub.FullName); } catch { }
}
totalDirs++;
entryCount++;
var dirLabel = opts.ShowDirSizes
? $"{sub.Name}/ ({FormatSize(subSize)})"
: $"{sub.Name}/";
sb.AppendLine($"{prefix}{connector}{dirLabel}");
BuildTree(sb, sub.FullName, prefix + childPrefix, currentDepth + 1, opts, context,
ref entryCount, ref totalFiles, ref totalDirs, ref subSize);
accumSize += subSize;
}
else
{
totalDirs++;
entryCount++;
sb.AppendLine($"{prefix}{connector}{sub.Name}/ [access denied]");
}
}
// 파일 출력
// ── Render files ──────────────────────────────────────────────────
foreach (var file in files)
{
if (entryCount >= MaxEntries) break;
@@ -156,16 +265,73 @@ public class FolderMapTool : IAgentTool
var isLast = index == totalItems;
var connector = isLast ? "└── " : "├── ";
var sizeStr = FormatSize(file.Length);
sb.AppendLine($"{prefix}{connector}{file.Name} ({sizeStr})");
string annotation = opts.SortBy switch
{
"modified" => $"({file.LastWriteTime:yyyy-MM-dd}, {FormatSize(file.Length)})",
"size" => $"({FormatSize(file.Length)})",
_ => $"({FormatSize(file.Length)})",
};
sb.AppendLine($"{prefix}{connector}{file.Name} {annotation}");
accumSize += file.Length;
totalFiles++;
entryCount++;
}
}
// ─── Helpers ──────────────────────────────────────────────────────────
private static bool MatchesExtension(FileInfo f, string extFilter, HashSet<string>? extSet)
{
if (extSet != null) return extSet.Contains(f.Extension);
if (!string.IsNullOrEmpty(extFilter))
return f.Extension.Equals(extFilter, StringComparison.OrdinalIgnoreCase);
return true;
}
private static long ComputeDirSize(string dir)
{
long total = 0;
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
{
try { total += new FileInfo(f).Length; } catch { }
}
return total;
}
private static long? ParseSizeString(string s)
{
if (string.IsNullOrWhiteSpace(s)) return null;
s = s.Trim();
if (s.EndsWith("GB", StringComparison.OrdinalIgnoreCase) && double.TryParse(s[..^2], out var gb))
return (long)(gb * 1024 * 1024 * 1024);
if (s.EndsWith("MB", StringComparison.OrdinalIgnoreCase) && double.TryParse(s[..^2], out var mb))
return (long)(mb * 1024 * 1024);
if (s.EndsWith("KB", StringComparison.OrdinalIgnoreCase) && double.TryParse(s[..^2], out var kb))
return (long)(kb * 1024);
if (s.EndsWith("B", StringComparison.OrdinalIgnoreCase) && double.TryParse(s[..^1], out var b))
return (long)b;
if (long.TryParse(s, out var raw)) return raw;
return null;
}
private static string FormatSize(long bytes) => bytes switch
{
< 1024 => $"{bytes} B",
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
_ => $"{bytes / (1024.0 * 1024.0):F1} MB",
< 1024L * 1024 * 1024 => $"{bytes / (1024.0 * 1024.0):F1} MB",
_ => $"{bytes / (1024.0 * 1024.0 * 1024.0):F2} GB",
};
// ─── Options record ────────────────────────────────────────────────────
private sealed record TreeOptions(
int MaxDepth,
bool IncludeFiles,
string ExtFilter,
HashSet<string>? ExtSet,
string SortBy,
bool ShowDirSizes,
DateTime? ModifiedAfter,
long? MaxFileSizeBytes);
}