Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/ZipTool.cs
lacvet 33c1db4dae
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) 기준 개발 이력을 반영함
2026-04-09 14:27:59 +09:00

168 lines
6.4 KiB
C#

using System.IO;
using System.IO.Compression;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 파일 압축(zip) / 해제 도구.
/// </summary>
public class ZipTool : IAgentTool
{
public string Name => "zip_tool";
public string Description =>
"Compress or extract zip archives. Actions: " +
"'compress' — create a zip file from files/folders; " +
"'extract' — extract a zip file to a directory; " +
"'list' — list contents of a zip file without extracting.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "Action to perform",
Enum = ["compress", "extract", "list"],
},
["zip_path"] = new()
{
Type = "string",
Description = "Path to the zip file (to create or extract)",
},
["source_path"] = new()
{
Type = "string",
Description = "Source file or directory path (for compress action)",
},
["dest_path"] = new()
{
Type = "string",
Description = "Destination directory (for extract action)",
},
},
Required = ["action", "zip_path"],
};
private const long MaxExtractSize = 500 * 1024 * 1024; // 500MB 제한
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").SafeGetString() ?? "";
var zipPath = args.GetProperty("zip_path").SafeGetString() ?? "";
if (!Path.IsPathRooted(zipPath))
zipPath = Path.Combine(context.WorkFolder, zipPath);
try
{
return Task.FromResult(action switch
{
"compress" => Compress(args, zipPath, context),
"extract" => Extract(args, zipPath, context),
"list" => ListContents(zipPath),
_ => ToolResult.Fail($"Unknown action: {action}"),
});
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"Zip 오류: {ex.Message}"));
}
}
private static ToolResult Compress(JsonElement args, string zipPath, AgentContext context)
{
if (!args.SafeTryGetProperty("source_path", out var sp))
return ToolResult.Fail("'source_path' is required for compress action");
var sourcePath = sp.SafeGetString() ?? "";
if (!Path.IsPathRooted(sourcePath))
sourcePath = Path.Combine(context.WorkFolder, sourcePath);
if (File.Exists(zipPath))
return ToolResult.Fail($"Zip file already exists: {zipPath}");
if (Directory.Exists(sourcePath))
{
ZipFile.CreateFromDirectory(sourcePath, zipPath, CompressionLevel.Optimal, includeBaseDirectory: false);
var info = new FileInfo(zipPath);
return ToolResult.Ok($"✓ Created {zipPath} ({info.Length / 1024}KB)", zipPath);
}
else if (File.Exists(sourcePath))
{
using var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create);
zip.CreateEntryFromFile(sourcePath, Path.GetFileName(sourcePath), CompressionLevel.Optimal);
var info = new FileInfo(zipPath);
return ToolResult.Ok($"✓ Created {zipPath} ({info.Length / 1024}KB)", zipPath);
}
else
{
return ToolResult.Fail($"Source not found: {sourcePath}");
}
}
private static ToolResult Extract(JsonElement args, string zipPath, AgentContext context)
{
if (!File.Exists(zipPath))
return ToolResult.Fail($"Zip file not found: {zipPath}");
var destPath = args.SafeTryGetProperty("dest_path", out var dp)
? dp.SafeGetString() ?? "" : "";
if (string.IsNullOrEmpty(destPath))
destPath = Path.Combine(Path.GetDirectoryName(zipPath) ?? context.WorkFolder,
Path.GetFileNameWithoutExtension(zipPath));
if (!Path.IsPathRooted(destPath))
destPath = Path.Combine(context.WorkFolder, destPath);
// 사이즈 체크
using (var check = ZipFile.OpenRead(zipPath))
{
var totalSize = check.Entries.Sum(e => e.Length);
if (totalSize > MaxExtractSize)
return ToolResult.Fail($"Uncompressed size ({totalSize / 1024 / 1024}MB) exceeds 500MB limit");
// 보안: 상위 경로 이탈 방지
foreach (var entry in check.Entries)
{
var fullPath = Path.GetFullPath(Path.Combine(destPath, entry.FullName));
if (!fullPath.StartsWith(Path.GetFullPath(destPath), StringComparison.OrdinalIgnoreCase))
return ToolResult.Fail($"Security: entry '{entry.FullName}' escapes destination directory");
}
}
Directory.CreateDirectory(destPath);
ZipFile.ExtractToDirectory(zipPath, destPath, overwriteFiles: true);
var fileCount = Directory.GetFiles(destPath, "*", SearchOption.AllDirectories).Length;
return ToolResult.Ok($"✓ Extracted {fileCount} files to {destPath}");
}
private static ToolResult ListContents(string zipPath)
{
if (!File.Exists(zipPath))
return ToolResult.Fail($"Zip file not found: {zipPath}");
using var zip = ZipFile.OpenRead(zipPath);
var sb = new System.Text.StringBuilder();
sb.AppendLine($"Archive: {Path.GetFileName(zipPath)} ({new FileInfo(zipPath).Length / 1024}KB)");
sb.AppendLine($"Entries: {zip.Entries.Count}");
sb.AppendLine();
sb.AppendLine($"{"Size",10} {"Compressed",10} {"Name"}");
sb.AppendLine(new string('-', 60));
var limit = Math.Min(zip.Entries.Count, 200);
foreach (var entry in zip.Entries.Take(limit))
{
sb.AppendLine($"{entry.Length,10} {entry.CompressedLength,10} {entry.FullName}");
}
if (zip.Entries.Count > limit)
sb.AppendLine($"\n... and {zip.Entries.Count - limit} more entries");
var totalUncompressed = zip.Entries.Sum(e => e.Length);
sb.AppendLine($"\nTotal uncompressed: {totalUncompressed / 1024}KB");
return ToolResult.Ok(sb.ToString());
}
}