모델 프로파일 기반 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,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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user