Initial commit to new repository

This commit is contained in:
2026-04-03 18:22:19 +09:00
commit 4458bb0f52
7672 changed files with 452440 additions and 0 deletions

View File

@@ -0,0 +1,402 @@
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 에이전트 도구 실행 전/후에 사용자 정의 스크립트(bat/ps1/cmd)를 실행하는 훅 러너.
/// <para>
/// 환경 변수로 도구 정보를 전달:
/// AX_TOOL_NAME — 도구 이름
/// AX_TOOL_TIMING — pre | post
/// AX_TOOL_INPUT — 도구 입력 JSON (최대 4KB)
/// AX_TOOL_OUTPUT — 도구 출력 (post만, 최대 4KB)
/// AX_TOOL_SUCCESS — true | false (post만)
/// AX_WORK_FOLDER — 작업 폴더 경로
/// </para>
/// </summary>
public static class AgentHookRunner
{
private const int MaxEnvValueLength = 4096;
/// <summary>
/// 지정된 타이밍(pre/post)에 해당하는 훅을 모두 실행합니다.
/// </summary>
/// <param name="hooks">설정에 등록된 전체 훅 목록</param>
/// <param name="toolName">실행된 도구 이름</param>
/// <param name="timing">"pre" 또는 "post"</param>
/// <param name="toolInput">도구 입력 JSON</param>
/// <param name="toolOutput">도구 출력 (post만)</param>
/// <param name="success">도구 실행 성공 여부 (post만)</param>
/// <param name="workFolder">작업 폴더 경로</param>
/// <param name="timeoutMs">스크립트 타임아웃 (밀리초)</param>
/// <param name="ct">취소 토큰</param>
/// <returns>각 훅의 실행 결과 (이름, 성공여부, 출력/에러)</returns>
public static async Task<List<HookExecutionResult>> RunAsync(
IReadOnlyList<AgentHookEntry> hooks,
string toolName,
string timing,
string? toolInput = null,
string? toolOutput = null,
bool success = true,
string? workFolder = null,
int timeoutMs = 10000,
CancellationToken ct = default)
{
var results = new List<HookExecutionResult>();
if (hooks == null || hooks.Count == 0) return results;
foreach (var hook in hooks)
{
if (!hook.Enabled) continue;
if (!string.Equals(hook.Timing, timing, StringComparison.OrdinalIgnoreCase)) continue;
// 도구 이름 매칭: "*" = 전체, 그 외 정확 매칭 (대소문자 무시)
if (hook.ToolName != "*" &&
!string.Equals(hook.ToolName, toolName, StringComparison.OrdinalIgnoreCase))
continue;
var result = await ExecuteHookAsync(hook, toolName, timing, toolInput, toolOutput, success, workFolder, timeoutMs, ct);
results.Add(result);
}
return results;
}
private static async Task<HookExecutionResult> ExecuteHookAsync(
AgentHookEntry hook,
string toolName,
string timing,
string? toolInput,
string? toolOutput,
bool success,
string? workFolder,
int timeoutMs,
CancellationToken ct)
{
try
{
if (string.IsNullOrWhiteSpace(hook.ScriptPath))
return new HookExecutionResult(hook.Name, false, "스크립트 경로가 비어 있습니다.");
var scriptPath = Environment.ExpandEnvironmentVariables(hook.ScriptPath);
if (!File.Exists(scriptPath))
return new HookExecutionResult(hook.Name, false, $"스크립트를 찾을 수 없습니다: {scriptPath}");
var ext = Path.GetExtension(scriptPath).ToLowerInvariant();
string fileName;
string arguments;
switch (ext)
{
case ".ps1":
fileName = "powershell.exe";
arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{scriptPath}\"";
break;
case ".bat":
case ".cmd":
fileName = "cmd.exe";
arguments = $"/c \"{scriptPath}\"";
break;
default:
return new HookExecutionResult(hook.Name, false, $"지원하지 않는 스크립트 확장자: {ext} (.bat/.cmd/.ps1만 허용)");
}
// 사용자 정의 추가 인수
if (!string.IsNullOrWhiteSpace(hook.Arguments))
arguments += $" {hook.Arguments}";
var psi = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
WorkingDirectory = workFolder ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
// 환경 변수로 도구 정보 전달
psi.EnvironmentVariables["AX_TOOL_NAME"] = toolName;
psi.EnvironmentVariables["AX_TOOL_TIMING"] = timing;
psi.EnvironmentVariables["AX_TOOL_INPUT"] = Truncate(toolInput, MaxEnvValueLength);
psi.EnvironmentVariables["AX_WORK_FOLDER"] = workFolder ?? "";
if (string.Equals(timing, "post", StringComparison.OrdinalIgnoreCase))
{
psi.EnvironmentVariables["AX_TOOL_OUTPUT"] = Truncate(toolOutput, MaxEnvValueLength);
psi.EnvironmentVariables["AX_TOOL_SUCCESS"] = success ? "true" : "false";
}
using var process = new Process { StartInfo = psi };
var stdOut = new StringBuilder();
var stdErr = new StringBuilder();
process.OutputDataReceived += (_, e) => { if (e.Data != null) stdOut.AppendLine(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data != null) stdErr.AppendLine(e.Data); };
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeoutMs);
try
{
await process.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
try { process.Kill(true); } catch { }
return new HookExecutionResult(hook.Name, false, $"타임아웃 ({timeoutMs}ms 초과)");
}
var exitCode = process.ExitCode;
var output = stdOut.ToString().TrimEnd();
var error = stdErr.ToString().TrimEnd();
if (exitCode != 0)
return new HookExecutionResult(hook.Name, false, $"종료 코드 {exitCode}: {(string.IsNullOrEmpty(error) ? output : error)}");
var displayOutput = string.IsNullOrEmpty(output) ? "(정상 완료)" : output;
if (TryParseStructuredPayload(
output,
out var updatedInput,
out var updatedPermissions,
out var additionalContext,
out var hookMessage))
{
if (!string.IsNullOrWhiteSpace(hookMessage))
displayOutput = hookMessage;
else if (string.IsNullOrWhiteSpace(displayOutput))
displayOutput = "(정상 완료)";
return new HookExecutionResult(
hook.Name,
true,
displayOutput,
updatedInput,
updatedPermissions,
additionalContext);
}
return new HookExecutionResult(hook.Name, true, displayOutput);
}
catch (Exception ex)
{
return new HookExecutionResult(hook.Name, false, $"훅 실행 예외: {ex.Message}");
}
}
private static string Truncate(string? value, int maxLen)
=> string.IsNullOrEmpty(value) ? "" : (value.Length <= maxLen ? value : value[..maxLen]);
private static bool TryParseStructuredPayload(
string rawOutput,
out JsonElement? updatedInput,
out Dictionary<string, string>? updatedPermissions,
out string? additionalContext,
out string? message)
{
updatedInput = null;
updatedPermissions = null;
additionalContext = null;
message = null;
if (string.IsNullOrWhiteSpace(rawOutput))
return false;
var candidate = rawOutput.Trim();
if (!TryParseJsonObject(candidate, out var root))
{
var lines = candidate
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (lines.Length == 0)
return false;
var lastLine = lines[^1];
if (!TryParseJsonObject(lastLine, out root))
return false;
}
var structured = false;
if (root.TryGetProperty("updatedInput", out var inputProp) &&
inputProp.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
{
updatedInput = inputProp.Clone();
structured = true;
}
if (TryExtractUpdatedPermissions(root, out var map))
{
updatedPermissions = map;
structured = true;
}
if (TryExtractAdditionalContext(root, out var parsedContext))
{
additionalContext = parsedContext;
structured = true;
}
if (root.TryGetProperty("message", out var msgProp) &&
msgProp.ValueKind == JsonValueKind.String)
{
var msg = msgProp.GetString();
if (!string.IsNullOrWhiteSpace(msg))
message = msg.Trim();
}
return structured;
}
private static bool TryParseJsonObject(string text, out JsonElement root)
{
root = default;
try
{
using var doc = JsonDocument.Parse(text);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return false;
root = doc.RootElement.Clone();
return true;
}
catch
{
return false;
}
}
private static bool TryExtractUpdatedPermissions(
JsonElement root,
out Dictionary<string, string>? updatedPermissions)
{
updatedPermissions = null;
JsonElement permProp;
if (!(root.TryGetProperty("updatedPermissions", out permProp)
|| root.TryGetProperty("permissionUpdates", out permProp)))
return false;
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (permProp.ValueKind == JsonValueKind.Object)
{
foreach (var item in permProp.EnumerateObject())
{
if (TryExtractPermissionValue(item.Value, out var normalized))
map[item.Name] = normalized;
}
}
else if (permProp.ValueKind == JsonValueKind.Array)
{
foreach (var entry in permProp.EnumerateArray())
{
if (entry.ValueKind != JsonValueKind.Object)
continue;
if (!entry.TryGetProperty("tool", out var toolProp) || toolProp.ValueKind != JsonValueKind.String)
continue;
var tool = toolProp.GetString()?.Trim();
if (string.IsNullOrWhiteSpace(tool))
continue;
if (entry.TryGetProperty("permission", out var permValue) &&
TryExtractPermissionValue(permValue, out var normalized))
{
map[tool] = normalized;
}
}
}
if (map.Count == 0)
return false;
updatedPermissions = map;
return true;
}
private static bool TryExtractPermissionValue(JsonElement permissionElement, out string normalized)
{
normalized = "";
if (permissionElement.ValueKind == JsonValueKind.String)
{
var text = permissionElement.GetString();
if (!string.IsNullOrWhiteSpace(text))
{
normalized = text.Trim();
return true;
}
}
else if (permissionElement.ValueKind == JsonValueKind.Object &&
permissionElement.TryGetProperty("permission", out var nestedPermission) &&
nestedPermission.ValueKind == JsonValueKind.String)
{
var text = nestedPermission.GetString();
if (!string.IsNullOrWhiteSpace(text))
{
normalized = text.Trim();
return true;
}
}
return false;
}
private static bool TryExtractAdditionalContext(JsonElement root, out string? additionalContext)
{
additionalContext = null;
if (!root.TryGetProperty("additionalContext", out var ctxProp))
return false;
if (ctxProp.ValueKind == JsonValueKind.String)
{
var text = ctxProp.GetString()?.Trim();
if (!string.IsNullOrWhiteSpace(text))
{
additionalContext = text;
return true;
}
return false;
}
if (ctxProp.ValueKind == JsonValueKind.Array)
{
var chunks = new List<string>();
foreach (var part in ctxProp.EnumerateArray())
{
if (part.ValueKind != JsonValueKind.String)
continue;
var text = part.GetString()?.Trim();
if (!string.IsNullOrWhiteSpace(text))
chunks.Add(text);
}
if (chunks.Count > 0)
{
additionalContext = string.Join("\n", chunks);
return true;
}
}
return false;
}
}
/// <summary>훅 실행 결과.</summary>
public record HookExecutionResult(
string HookName,
bool Success,
string Output,
JsonElement? UpdatedInput = null,
Dictionary<string, string>? UpdatedPermissions = null,
string? AdditionalContext = null);

View File

@@ -0,0 +1,246 @@
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services;
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
private static int GetMaxParallelToolConcurrency()
{
var raw = Environment.GetEnvironmentVariable("AXCOPILOT_MAX_PARALLEL_TOOLS");
if (int.TryParse(raw, out var parsed) && parsed > 0)
return Math.Min(parsed, 12);
return 4;
}
// 읽기 전용 도구 (파일 상태를 변경하지 않음)
private static readonly HashSet<string> ReadOnlyTools = new(StringComparer.OrdinalIgnoreCase)
{
"file_read", "glob", "grep_tool", "folder_map", "document_read",
"search_codebase", "code_search", "env_tool", "datetime_tool",
"dev_env_detect", "memory", "skill_manager", "json_tool",
"regex_tool", "base64_tool", "hash_tool", "image_analyze",
};
/// <summary>도구 호출을 병렬 가능 / 순차 필수로 분류합니다.</summary>
private static (List<LlmService.ContentBlock> Parallel, List<LlmService.ContentBlock> Sequential)
ClassifyToolCalls(List<LlmService.ContentBlock> calls)
{
var parallel = new List<LlmService.ContentBlock>();
var sequential = new List<LlmService.ContentBlock>();
var collectParallelPrefix = true;
foreach (var call in calls)
{
var requestedToolName = call.ToolName ?? "";
var normalizedToolName = NormalizeAliasToken(requestedToolName);
var classificationToolName = ToolAliasMap.TryGetValue(normalizedToolName, out var mappedToolName)
? mappedToolName
: requestedToolName;
if (collectParallelPrefix && ReadOnlyTools.Contains(classificationToolName))
parallel.Add(call);
else
{
collectParallelPrefix = false;
sequential.Add(call);
}
}
// 읽기 전용 도구가 1개뿐이면 병렬화 의미 없음
if (parallel.Count <= 1)
{
sequential.InsertRange(0, parallel);
parallel.Clear();
}
return (parallel, sequential);
}
/// <summary>병렬 실행용 가변 상태.</summary>
private class ParallelState
{
public int CurrentStep;
public int TotalToolCalls;
public int MaxIterations;
public int ConsecutiveReadOnlySuccessTools;
public int ConsecutiveNonMutatingSuccessTools;
public int ConsecutiveErrors;
public int StatsSuccessCount;
public int StatsFailCount;
public int StatsInputTokens;
public int StatsOutputTokens;
public int StatsRepeatedFailureBlocks;
public int StatsRecoveredAfterFailure;
public bool RecoveryPendingAfterFailure;
public string? LastFailedToolSignature;
public int RepeatedFailedToolSignatureCount;
}
/// <summary>읽기 전용 도구들을 병렬 실행합니다.</summary>
private async Task ExecuteToolsInParallelAsync(
List<LlmService.ContentBlock> calls,
List<ChatMessage> messages,
AgentContext context,
List<string> planSteps,
ParallelState state,
int baseMax, int maxRetry,
Models.LlmSettings llm,
int iteration,
CancellationToken ct,
List<string> statsUsedTools)
{
EmitEvent(AgentEventType.Thinking, "", $"읽기 전용 도구 {calls.Count}개를 병렬 실행 중...");
var activeToolNames = _tools.GetActiveTools(llm.DisabledTools)
.Select(t => t.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
var executableCalls = new List<LlmService.ContentBlock>();
foreach (var call in calls)
{
var resolvedToolName = ResolveRequestedToolName(call.ToolName, activeToolNames);
var effectiveCall = string.Equals(call.ToolName, resolvedToolName, StringComparison.OrdinalIgnoreCase)
? call
: new LlmService.ContentBlock
{
Type = call.Type,
Text = call.Text,
ToolName = resolvedToolName,
ToolId = call.ToolId,
ToolInput = call.ToolInput,
};
var signature = BuildToolCallSignature(effectiveCall);
if (ShouldBlockRepeatedFailedCall(
signature,
state.LastFailedToolSignature,
state.RepeatedFailedToolSignatureCount,
maxRetry))
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId,
call.ToolName,
BuildRepeatedFailureGuardMessage(call.ToolName, state.RepeatedFailedToolSignatureCount, maxRetry)));
EmitEvent(
AgentEventType.Thinking,
call.ToolName,
$"병렬 배치에서도 동일 호출 반복 실패를 감지해 실행을 건너뜁니다 ({state.RepeatedFailedToolSignatureCount}/{maxRetry})");
state.StatsRepeatedFailureBlocks++;
continue;
}
executableCalls.Add(effectiveCall);
}
if (executableCalls.Count == 0)
return;
var maxConcurrency = GetMaxParallelToolConcurrency();
using var gate = new SemaphoreSlim(maxConcurrency, maxConcurrency);
var tasks = executableCalls.Select(async call =>
{
await gate.WaitAsync(ct).ConfigureAwait(false);
var tool = _tools.Get(call.ToolName);
try
{
if (tool == null)
return (call, ToolResult.Fail($"알 수 없는 도구: {call.ToolName}"), 0L);
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
var input = call.ToolInput ?? JsonDocument.Parse("{}").RootElement;
var result = await ExecuteToolWithTimeoutAsync(tool, call.ToolName, input, context, ct);
sw.Stop();
return (call, result, sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
sw.Stop();
return (call, ToolResult.Fail($"도구 실행 오류: {ex.Message}"), sw.ElapsedMilliseconds);
}
}
finally
{
gate.Release();
}
}).ToList();
var results = await Task.WhenAll(tasks);
// 결과를 순서대로 메시지에 추가
foreach (var (call, result, elapsed) in results)
{
EmitEvent(
result.Success ? AgentEventType.ToolResult : AgentEventType.Error,
call.ToolName,
TruncateOutput(result.Output, 200),
result.FilePath,
elapsedMs: elapsed,
iteration: iteration);
if (result.Success) state.StatsSuccessCount++; else state.StatsFailCount++;
state.ConsecutiveReadOnlySuccessTools = UpdateConsecutiveReadOnlySuccessTools(
state.ConsecutiveReadOnlySuccessTools,
call.ToolName,
result.Success);
state.ConsecutiveNonMutatingSuccessTools = UpdateConsecutiveNonMutatingSuccessTools(
state.ConsecutiveNonMutatingSuccessTools,
call.ToolName,
result.Success);
if (!statsUsedTools.Contains(call.ToolName))
statsUsedTools.Add(call.ToolName);
state.TotalToolCalls++;
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName, TruncateOutput(result.Output, 4000)));
if (!result.Success)
{
var signature = BuildToolCallSignature(call);
if (string.Equals(state.LastFailedToolSignature, signature, StringComparison.Ordinal))
state.RepeatedFailedToolSignatureCount++;
else
{
state.LastFailedToolSignature = signature;
state.RepeatedFailedToolSignatureCount = 1;
}
state.ConsecutiveErrors++;
state.RecoveryPendingAfterFailure = true;
if (state.ConsecutiveErrors > maxRetry)
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName,
$"[FAILED after retries] {TruncateOutput(result.Output, 500)}"));
}
}
else
{
state.ConsecutiveErrors = 0;
if (state.RecoveryPendingAfterFailure)
{
state.StatsRecoveredAfterFailure++;
state.RecoveryPendingAfterFailure = false;
}
state.LastFailedToolSignature = null;
state.RepeatedFailedToolSignatureCount = 0;
}
// 감사 로그
if (llm.EnableAuditLog)
{
AuditLogService.LogToolCall(
_conversationId, ActiveTab ?? "",
call.ToolName,
call.ToolInput?.ToString() ?? "",
TruncateOutput(result.Output, 500),
result.FilePath, result.Success);
}
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public partial class AgentLoopService
{
private static (bool ShouldRun, List<LlmService.ContentBlock> ParallelBatch, List<LlmService.ContentBlock> SequentialBatch)
CreateParallelExecutionPlan(bool parallelEnabled, List<LlmService.ContentBlock> toolCalls)
{
if (!parallelEnabled || toolCalls.Count <= 1)
return (false, new List<LlmService.ContentBlock>(), toolCalls);
var (parallelBatch, sequentialBatch) = ClassifyToolCalls(toolCalls);
return (true, parallelBatch, sequentialBatch);
}
private static (
string? LastFailedToolSignature,
int RepeatedFailedToolSignatureCount,
int ConsecutiveErrors,
bool CanRetry)
ComputeFailureTransitionState(
string currentToolCallSignature,
string? lastFailedToolSignature,
int repeatedFailedToolSignatureCount,
int consecutiveErrors,
int maxRetry)
{
if (string.Equals(lastFailedToolSignature, currentToolCallSignature, StringComparison.Ordinal))
repeatedFailedToolSignatureCount++;
else
{
lastFailedToolSignature = currentToolCallSignature;
repeatedFailedToolSignatureCount = 1;
}
consecutiveErrors++;
var canRetry = consecutiveErrors <= maxRetry;
return (lastFailedToolSignature, repeatedFailedToolSignatureCount, consecutiveErrors, canRetry);
}
private static bool ShouldRunPostToolVerification(
string? activeTab,
string? toolName,
bool toolSucceeded,
bool codeVerificationEnabled,
bool coworkVerificationEnabled)
{
if (!toolSucceeded || string.IsNullOrWhiteSpace(toolName))
return false;
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase))
return codeVerificationEnabled && IsCodeVerificationTarget(toolName);
return coworkVerificationEnabled && IsDocumentCreationTool(toolName);
}
private static (bool ShouldContinue, string? TerminalResponse, string? ToolResultMessage) EvaluateDevStepDecision(string? decision)
{
if (string.Equals(decision, "중단", StringComparison.Ordinal))
return (false, "사용자가 개발자 모드에서 실행을 중단했습니다.", null);
if (string.Equals(decision, "건너뛰기", StringComparison.Ordinal))
return (true, null, "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다.");
return (false, null, null);
}
private static (bool ShouldContinue, string? TerminalResponse, string? ToolResultMessage) EvaluateScopeDecision(string? decision)
{
if (string.Equals(decision, "취소", StringComparison.Ordinal))
return (false, "사용자가 작업을 취소했습니다.", null);
if (string.Equals(decision, "건너뛰기", StringComparison.Ordinal))
return (true, null, "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다.");
return (false, null, null);
}
}

View File

@@ -0,0 +1,88 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>Base64/URL 인코딩·디코딩 도구.</summary>
public class Base64Tool : IAgentTool
{
public string Name => "base64_tool";
public string Description =>
"Encode or decode Base64 and URL strings. Actions: " +
"'b64encode' — encode text to Base64; " +
"'b64decode' — decode Base64 to text; " +
"'urlencode' — URL-encode text; " +
"'urldecode' — URL-decode text; " +
"'b64file' — encode a file to Base64 (max 5MB).";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "Action to perform",
Enum = ["b64encode", "b64decode", "urlencode", "urldecode", "b64file"],
},
["text"] = new()
{
Type = "string",
Description = "Text to encode/decode",
},
["file_path"] = new()
{
Type = "string",
Description = "File path for b64file action",
},
},
Required = ["action"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var text = args.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
try
{
return Task.FromResult(action switch
{
"b64encode" => ToolResult.Ok(Convert.ToBase64String(Encoding.UTF8.GetBytes(text))),
"b64decode" => ToolResult.Ok(Encoding.UTF8.GetString(Convert.FromBase64String(text))),
"urlencode" => ToolResult.Ok(Uri.EscapeDataString(text)),
"urldecode" => ToolResult.Ok(Uri.UnescapeDataString(text)),
"b64file" => EncodeFile(args, context),
_ => ToolResult.Fail($"Unknown action: {action}"),
});
}
catch (FormatException)
{
return Task.FromResult(ToolResult.Fail("Invalid Base64 string"));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"오류: {ex.Message}"));
}
}
private static ToolResult EncodeFile(JsonElement args, AgentContext context)
{
if (!args.TryGetProperty("file_path", out var fp))
return ToolResult.Fail("'file_path' is required for b64file action");
var path = fp.GetString() ?? "";
if (!Path.IsPathRooted(path)) path = Path.Combine(context.WorkFolder, path);
if (!File.Exists(path)) return ToolResult.Fail($"File not found: {path}");
var info = new FileInfo(path);
if (info.Length > 5 * 1024 * 1024)
return ToolResult.Fail($"File too large ({info.Length / 1024}KB). Max 5MB.");
var bytes = File.ReadAllBytes(path);
var b64 = Convert.ToBase64String(bytes);
if (b64.Length > 10000)
return ToolResult.Ok($"Base64 ({b64.Length} chars, first 500):\n{b64[..500]}...");
return ToolResult.Ok(b64);
}
}

View File

@@ -0,0 +1,99 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 배치파일(.bat) / PowerShell 스크립트(.ps1)를 생성하는 내장 스킬.
/// 파일 생성만 수행하며 자동 실행하지 않습니다.
/// 시스템 수준 명령(레지스트리, 서비스, 드라이버 등)은 차단합니다.
/// </summary>
public class BatchSkill : IAgentTool
{
public string Name => "script_create";
public string Description => "Create a batch (.bat) or PowerShell (.ps1) script file. The script is ONLY created, NOT executed. System-level commands are blocked.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "Output file path (.bat or .ps1). Relative to work folder." },
["content"] = new() { Type = "string", Description = "Script content. Each line should have Korean comments explaining the command." },
["description"] = new() { Type = "string", Description = "Brief description of what this script does." },
},
Required = ["path", "content"]
};
// 시스템 수준 명령 차단 목록
private static readonly string[] BlockedCommands =
[
"reg ", "reg.exe", "regedit",
"sc ", "sc.exe",
"net stop", "net start", "net user",
"bcdedit", "diskpart", "format ",
"shutdown", "schtasks",
"wmic", "powercfg",
"Set-Service", "Stop-Service", "Start-Service",
"New-Service", "Remove-Service",
"Set-ItemProperty.*HKLM", "Set-ItemProperty.*HKCU",
"Remove-Item.*-Recurse.*-Force",
];
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var content = args.GetProperty("content").GetString() ?? "";
var desc = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
// 확장자 검증
var ext = Path.GetExtension(fullPath).ToLowerInvariant();
if (ext != ".bat" && ext != ".ps1" && ext != ".cmd")
return ToolResult.Fail("지원하는 스크립트 형식: .bat, .cmd, .ps1");
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
// 시스템 명령 차단 검사
var contentLower = content.ToLowerInvariant();
foreach (var blocked in BlockedCommands)
{
if (contentLower.Contains(blocked.ToLowerInvariant()))
return ToolResult.Fail($"시스템 수준 명령이 포함되어 차단됨: {blocked.Trim()}");
}
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
try
{
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
// 파일 상단에 설명 주석 추가
var sb = new StringBuilder();
if (!string.IsNullOrEmpty(desc))
{
var commentPrefix = ext == ".ps1" ? "#" : "REM";
sb.AppendLine($"{commentPrefix} === {desc} ===");
sb.AppendLine($"{commentPrefix} 이 스크립트는 AX Copilot에 의해 생성되었습니다.");
sb.AppendLine($"{commentPrefix} 실행 전 내용을 반드시 확인하세요.");
sb.AppendLine();
}
sb.Append(content);
await File.WriteAllTextAsync(fullPath, sb.ToString(), new UTF8Encoding(false), ct);
return ToolResult.Ok(
$"스크립트 파일 생성 완료: {fullPath}\n형식: {ext}, 설명: {(string.IsNullOrEmpty(desc) ? "()" : desc)}\n⚠ 자동 실행되지 않습니다. 내용을 확인한 후 직접 실행하세요.",
fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"스크립트 생성 실패: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,207 @@
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 프로젝트 빌드/테스트 실행 도구.
/// 작업 폴더의 프로젝트 타입을 자동 감지하고 적절한 빌드/테스트 명령을 실행합니다.
/// 사내 환경에서 설치된 도구만 사용하며, 빌더를 직접 설치하지 않습니다.
/// </summary>
public class BuildRunTool : IAgentTool
{
public string Name => "build_run";
public string Description =>
"Detect project type and run build/test commands. " +
"Supports: .NET (dotnet), Maven (mvn), Gradle, Node.js (npm), Python (pytest), CMake, Make. " +
"Actions: detect (show project type), build, test, run, custom (run arbitrary command with longer timeout).";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "Action to perform: detect, build, test, run, lint, format, custom",
Enum = ["detect", "build", "test", "run", "lint", "format", "custom"],
},
["command"] = new()
{
Type = "string",
Description = "Custom command to execute (required for action='custom')",
},
["project_path"] = new()
{
Type = "string",
Description = "Subdirectory within work folder (optional, defaults to work folder root)",
},
},
Required = ["action"]
};
// ProcessTool과 동일한 위험 명령 패턴
private static readonly string[] DangerousPatterns =
[
"format ", "del /s", "rd /s", "rmdir /s", "rm -rf",
"Remove-Item -Recurse -Force",
"Stop-Computer", "Restart-Computer",
"shutdown", "taskkill /f",
"reg delete", "reg add",
"net user", "net localgroup",
"schtasks /create", "schtasks /delete",
];
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var action = args.GetProperty("action").GetString() ?? "detect";
var customCmd = args.TryGetProperty("command", out var cmd) ? cmd.GetString() ?? "" : "";
var subPath = args.TryGetProperty("project_path", out var pp) ? pp.GetString() ?? "" : "";
var workDir = context.WorkFolder;
if (!string.IsNullOrEmpty(subPath))
workDir = Path.Combine(workDir, subPath);
if (string.IsNullOrEmpty(workDir) || !Directory.Exists(workDir))
return ToolResult.Fail($"작업 폴더가 유효하지 않습니다: {workDir}");
// 프로젝트 타입 감지
var project = DetectProjectType(workDir);
if (action == "detect")
{
if (project == null)
return ToolResult.Ok($"프로젝트 감지 실패: {workDir}\n알려진 프로젝트 마커 파일이 없습니다.");
return ToolResult.Ok(
$"프로젝트 감지 완료:\n" +
$" 타입: {project.Type}\n" +
$" 마커: {project.Marker}\n" +
$" 빌드: {project.BuildCommand}\n" +
$" 테스트: {project.TestCommand}\n" +
$" 실행: {project.RunCommand}\n" +
$" 린트: {(string.IsNullOrEmpty(project.LintCommand) ? "()" : project.LintCommand)}\n" +
$" 포맷: {(string.IsNullOrEmpty(project.FormatCommand) ? "()" : project.FormatCommand)}\n" +
$" 경로: {workDir}");
}
// 실행할 명령 결정
string? command;
if (action == "custom")
{
if (string.IsNullOrWhiteSpace(customCmd))
return ToolResult.Fail("custom 액션에는 command 파라미터가 필요합니다.");
command = customCmd;
}
else if (project == null)
{
return ToolResult.Fail("프로젝트 타입을 감지할 수 없습니다. action='custom'으로 직접 명령을 지정하세요.");
}
else
{
command = action switch
{
"build" => project.BuildCommand,
"test" => project.TestCommand,
"run" => project.RunCommand,
"lint" => string.IsNullOrEmpty(project.LintCommand) ? null : project.LintCommand,
"format" => string.IsNullOrEmpty(project.FormatCommand) ? null : project.FormatCommand,
_ => project.BuildCommand,
};
if (command == null)
return ToolResult.Fail($"이 프로젝트 타입({project.Type})에서 '{action}' 작업은 지원되지 않습니다.");
}
// 위험 명령 검사
foreach (var pattern in DangerousPatterns)
{
if (command.Contains(pattern, StringComparison.OrdinalIgnoreCase))
return ToolResult.Fail($"차단된 명령 패턴: {pattern}");
}
// 쓰기 권한 확인
if (!await context.CheckWritePermissionAsync(Name, workDir))
return ToolResult.Fail("빌드 실행 권한이 거부되었습니다.");
// 명령 실행 (ProcessTool 패턴)
var app = System.Windows.Application.Current as App;
var timeout = app?.SettingsService?.Settings.Llm.Code.BuildTimeout ?? 120;
try
{
var psi = new ProcessStartInfo("cmd.exe", $"/C {command}")
{
WorkingDirectory = workDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
using var proc = Process.Start(psi);
if (proc == null) return ToolResult.Fail("프로세스 시작 실패");
var stdoutTask = proc.StandardOutput.ReadToEndAsync(cts.Token);
var stderrTask = proc.StandardError.ReadToEndAsync(cts.Token);
await proc.WaitForExitAsync(cts.Token);
var stdout = await stdoutTask;
var stderr = await stderrTask;
// 출력 제한 (8000자)
if (stdout.Length > 8000) stdout = stdout[..8000] + "\n... (출력 잘림)";
if (stderr.Length > 4000) stderr = stderr[..4000] + "\n... (출력 잘림)";
var sb = new StringBuilder();
sb.AppendLine($"[{action}] {command}");
sb.AppendLine($"[Exit code: {proc.ExitCode}]");
if (!string.IsNullOrWhiteSpace(stdout)) sb.AppendLine(stdout);
if (!string.IsNullOrWhiteSpace(stderr)) sb.AppendLine($"[stderr]\n{stderr}");
return proc.ExitCode == 0
? ToolResult.Ok(sb.ToString())
: ToolResult.Fail(sb.ToString());
}
catch (OperationCanceledException)
{
return ToolResult.Fail($"빌드 타임아웃 ({timeout}초 초과): {command}");
}
catch (Exception ex)
{
return ToolResult.Fail($"실행 오류: {ex.Message}");
}
}
private record ProjectInfo(string Type, string Marker, string BuildCommand, string TestCommand, string RunCommand, string LintCommand, string FormatCommand);
private static ProjectInfo? DetectProjectType(string dir)
{
if (Directory.GetFiles(dir, "*.sln").Length > 0)
return new(".NET Solution", "*.sln", "dotnet build", "dotnet test", "dotnet run", "dotnet format --verify-no-changes", "dotnet format");
if (Directory.GetFiles(dir, "*.csproj").Length > 0)
return new(".NET Project", "*.csproj", "dotnet build", "dotnet test", "dotnet run", "dotnet format --verify-no-changes", "dotnet format");
if (File.Exists(Path.Combine(dir, "pom.xml")))
return new("Maven", "pom.xml", "mvn compile", "mvn test", "mvn exec:java", "mvn checkstyle:check", "");
if (Directory.GetFiles(dir, "build.gradle*").Length > 0)
return new("Gradle", "build.gradle", "gradle build", "gradle test", "gradle run", "gradle check", "");
if (File.Exists(Path.Combine(dir, "package.json")))
return new("Node.js", "package.json", "npm run build", "npm test", "npm start", "npx eslint .", "npx prettier --write .");
if (File.Exists(Path.Combine(dir, "CMakeLists.txt")))
return new("CMake", "CMakeLists.txt", "cmake --build build", "ctest --test-dir build", "", "", "");
if (File.Exists(Path.Combine(dir, "pyproject.toml")))
return new("Python (pyproject)", "pyproject.toml", "python -m build", "python -m pytest", "python -m", "python -m ruff check .", "python -m black .");
if (File.Exists(Path.Combine(dir, "setup.py")))
return new("Python (setup.py)", "setup.py", "python setup.py build", "python -m pytest", "python", "python -m ruff check .", "python -m black .");
if (File.Exists(Path.Combine(dir, "Makefile")))
return new("Make", "Makefile", "make", "make test", "make run", "make lint", "make format");
if (Directory.GetFiles(dir, "*.py").Length > 0)
return new("Python Scripts", "*.py", "", "python -m pytest", "python", "python -m ruff check .", "python -m black .");
return null;
}
}

View File

@@ -0,0 +1,537 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// CSS/SVG 기반 차트를 HTML 파일로 생성하는 스킬.
/// bar, line, pie(donut), radar, area 차트를 지원하며 TemplateService 무드 스타일을 적용합니다.
/// </summary>
public class ChartSkill : IAgentTool
{
public string Name => "chart_create";
public string Description =>
"Create a styled HTML chart document with CSS/SVG-based charts. " +
"Supports chart types: bar, horizontal_bar, stacked_bar, line, area, pie, donut, radar, progress, comparison. " +
"Multiple charts can be placed in one document using the 'charts' array. " +
"Applies design mood from TemplateService (modern, professional, creative, etc.).";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "Output file path (.html). Relative to work folder." },
["title"] = new() { Type = "string", Description = "Document title" },
["charts"] = new()
{
Type = "array",
Description = "Array of chart objects. Each chart: " +
"{\"type\": \"bar|horizontal_bar|stacked_bar|line|area|pie|donut|radar|progress|comparison\", " +
"\"title\": \"Chart Title\", " +
"\"labels\": [\"A\",\"B\",\"C\"], " +
"\"datasets\": [{\"name\": \"Series1\", \"values\": [10,20,30], \"color\": \"#4B5EFC\"}], " +
"\"unit\": \"%\"}",
Items = new ToolProperty { Type = "object" },
},
["mood"] = new() { Type = "string", Description = "Design mood: modern, professional, creative, dark, dashboard, etc. Default: dashboard" },
["layout"] = new() { Type = "string", Description = "Chart layout: 'single' (one per row) or 'grid' (2-column grid). Default: single" },
},
Required = ["path", "title", "charts"]
};
// 기본 차트 팔레트
private static readonly string[] Palette =
[
"#4B5EFC", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6",
"#06B6D4", "#EC4899", "#84CC16", "#F97316", "#6366F1",
];
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "chart.html";
var title = args.GetProperty("title").GetString() ?? "Chart";
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "dashboard" : "dashboard";
var layout = args.TryGetProperty("layout", out var l) ? l.GetString() ?? "single" : "single";
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
if (!fullPath.EndsWith(".html", StringComparison.OrdinalIgnoreCase))
fullPath += ".html";
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
if (!args.TryGetProperty("charts", out var chartsEl) || chartsEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("charts 파라미터가 필요합니다 (배열 형식).");
var chartCount = chartsEl.GetArrayLength();
var body = new StringBuilder();
body.AppendLine($"<h1>{Escape(title)}</h1>");
if (layout == "grid" && chartCount > 1)
body.AppendLine("<div class=\"grid-2\">");
int chartIdx = 0;
foreach (var chartEl in chartsEl.EnumerateArray())
{
var chartHtml = RenderChart(chartEl, chartIdx);
body.AppendLine("<div class=\"card\" style=\"margin-bottom:20px;\">");
body.AppendLine(chartHtml);
body.AppendLine("</div>");
chartIdx++;
}
if (layout == "grid" && chartCount > 1)
body.AppendLine("</div>");
var css = TemplateService.GetCss(mood) + "\n" + ChartCss;
var html = $@"<!DOCTYPE html>
<html lang=""ko"">
<head>
<meta charset=""UTF-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>{Escape(title)}</title>
<style>
{css}
</style>
</head>
<body>
<div class=""container"">
{body}
</div>
</body>
</html>";
await File.WriteAllTextAsync(fullPath, html, Encoding.UTF8, ct);
return ToolResult.Ok($"차트 문서 생성 완료: {fullPath} ({chartCount}개 차트)", fullPath);
}
private string RenderChart(JsonElement chart, int idx)
{
var type = chart.TryGetProperty("type", out var t) ? t.GetString() ?? "bar" : "bar";
var chartTitle = chart.TryGetProperty("title", out var ct) ? ct.GetString() ?? "" : "";
var unit = chart.TryGetProperty("unit", out var u) ? u.GetString() ?? "" : "";
var labels = ParseStringArray(chart, "labels");
var datasets = ParseDatasets(chart);
var sb = new StringBuilder();
if (!string.IsNullOrEmpty(chartTitle))
sb.AppendLine($"<h3 style=\"margin-bottom:12px;\">{Escape(chartTitle)}</h3>");
switch (type)
{
case "bar":
sb.Append(RenderBarChart(labels, datasets, unit, false));
break;
case "horizontal_bar":
sb.Append(RenderBarChart(labels, datasets, unit, true));
break;
case "stacked_bar":
sb.Append(RenderStackedBar(labels, datasets, unit));
break;
case "line":
case "area":
sb.Append(RenderLineChart(labels, datasets, unit, type == "area"));
break;
case "pie":
case "donut":
sb.Append(RenderPieChart(labels, datasets, type == "donut"));
break;
case "progress":
sb.Append(RenderProgressChart(labels, datasets, unit));
break;
case "comparison":
sb.Append(RenderComparisonChart(labels, datasets, unit));
break;
case "radar":
sb.Append(RenderRadarChart(labels, datasets));
break;
default:
sb.Append(RenderBarChart(labels, datasets, unit, false));
break;
}
// 범례
if (datasets.Count > 1)
{
sb.AppendLine("<div class=\"chart-legend\">");
foreach (var ds in datasets)
sb.AppendLine($"<span class=\"legend-item\"><span class=\"legend-dot\" style=\"background:{ds.Color}\"></span>{Escape(ds.Name)}</span>");
sb.AppendLine("</div>");
}
return sb.ToString();
}
// ─── Bar Chart ───────────────────────────────────────────────────────
private static string RenderBarChart(List<string> labels, List<Dataset> datasets, string unit, bool horizontal)
{
var maxVal = datasets.SelectMany(d => d.Values).DefaultIfEmpty(1).Max();
if (maxVal <= 0) maxVal = 1;
var sb = new StringBuilder();
if (horizontal)
{
sb.AppendLine("<div class=\"hbar-chart\">");
for (int i = 0; i < labels.Count; i++)
{
var val = datasets.Count > 0 && i < datasets[0].Values.Count ? datasets[0].Values[i] : 0;
var pct = (int)(val / maxVal * 100);
var color = datasets.Count > 0 ? datasets[0].Color : Palette[0];
sb.AppendLine($"<div class=\"hbar-row\"><span class=\"hbar-label\">{Escape(labels[i])}</span>");
sb.AppendLine($"<div class=\"hbar-track\"><div class=\"hbar-fill\" style=\"width:{pct}%;background:{color};\"></div></div>");
sb.AppendLine($"<span class=\"hbar-value\">{val:G}{unit}</span></div>");
}
sb.AppendLine("</div>");
}
else
{
sb.AppendLine("<div class=\"vbar-chart\">");
sb.AppendLine("<div class=\"vbar-bars\">");
for (int i = 0; i < labels.Count; i++)
{
sb.AppendLine("<div class=\"vbar-group\">");
foreach (var ds in datasets)
{
var val = i < ds.Values.Count ? ds.Values[i] : 0;
var pct = (int)(val / maxVal * 100);
sb.AppendLine($"<div class=\"vbar-bar\" style=\"height:{pct}%;background:{ds.Color};\" title=\"{val:G}{unit}\"></div>");
}
sb.AppendLine($"<div class=\"vbar-label\">{Escape(labels[i])}</div>");
sb.AppendLine("</div>");
}
sb.AppendLine("</div></div>");
}
return sb.ToString();
}
// ─── Stacked Bar ─────────────────────────────────────────────────────
private static string RenderStackedBar(List<string> labels, List<Dataset> datasets, string unit)
{
var sb = new StringBuilder();
sb.AppendLine("<div class=\"hbar-chart\">");
for (int i = 0; i < labels.Count; i++)
{
var total = datasets.Sum(ds => i < ds.Values.Count ? ds.Values[i] : 0);
sb.AppendLine($"<div class=\"hbar-row\"><span class=\"hbar-label\">{Escape(labels[i])}</span>");
sb.AppendLine("<div class=\"hbar-track\" style=\"display:flex;\">");
foreach (var ds in datasets)
{
var val = i < ds.Values.Count ? ds.Values[i] : 0;
var pct = total > 0 ? (int)(val / total * 100) : 0;
sb.AppendLine($"<div style=\"width:{pct}%;background:{ds.Color};height:100%;\" title=\"{ds.Name}: {val:G}{unit}\"></div>");
}
sb.AppendLine($"</div><span class=\"hbar-value\">{total:G}{unit}</span></div>");
}
sb.AppendLine("</div>");
return sb.ToString();
}
// ─── Line / Area Chart (SVG) ─────────────────────────────────────────
private static string RenderLineChart(List<string> labels, List<Dataset> datasets, string unit, bool isArea)
{
var allVals = datasets.SelectMany(d => d.Values).ToList();
var maxVal = allVals.DefaultIfEmpty(1).Max();
var minVal = allVals.DefaultIfEmpty(0).Min();
if (maxVal <= minVal) maxVal = minVal + 1;
int w = 600, h = 300, padL = 50, padR = 20, padT = 20, padB = 40;
var chartW = w - padL - padR;
var chartH = h - padT - padB;
var n = labels.Count;
var sb = new StringBuilder();
sb.AppendLine($"<svg viewBox=\"0 0 {w} {h}\" class=\"line-chart-svg\" preserveAspectRatio=\"xMidYMid meet\">");
// Y축 그리드
for (int i = 0; i <= 4; i++)
{
var y = padT + chartH - (chartH * i / 4.0);
var val = minVal + (maxVal - minVal) * i / 4.0;
sb.AppendLine($"<line x1=\"{padL}\" y1=\"{y:F0}\" x2=\"{w - padR}\" y2=\"{y:F0}\" stroke=\"#E5E7EB\" stroke-width=\"1\"/>");
sb.AppendLine($"<text x=\"{padL - 8}\" y=\"{y + 4:F0}\" text-anchor=\"end\" fill=\"#6B7280\" font-size=\"11\">{val:G3}{unit}</text>");
}
// X축 라벨
for (int i = 0; i < n; i++)
{
var x = padL + (n > 1 ? chartW * i / (double)(n - 1) : chartW / 2.0);
sb.AppendLine($"<text x=\"{x:F0}\" y=\"{h - 8}\" text-anchor=\"middle\" fill=\"#6B7280\" font-size=\"11\">{Escape(labels[i])}</text>");
}
// 데이터셋
foreach (var ds in datasets)
{
var points = new List<(double x, double y)>();
for (int i = 0; i < Math.Min(n, ds.Values.Count); i++)
{
var x = padL + (n > 1 ? chartW * i / (double)(n - 1) : chartW / 2.0);
var y = padT + chartH - ((ds.Values[i] - minVal) / (maxVal - minVal) * chartH);
points.Add((x, y));
}
var pathData = string.Join(" ", points.Select((p, i) => $"{(i == 0 ? "M" : "L")}{p.x:F1},{p.y:F1}"));
if (isArea && points.Count > 1)
{
var areaPath = pathData + $" L{points.Last().x:F1},{padT + chartH} L{points.First().x:F1},{padT + chartH} Z";
sb.AppendLine($"<path d=\"{areaPath}\" fill=\"{ds.Color}\" opacity=\"0.15\"/>");
}
sb.AppendLine($"<path d=\"{pathData}\" fill=\"none\" stroke=\"{ds.Color}\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>");
// 데이터 포인트
foreach (var (px, py) in points)
sb.AppendLine($"<circle cx=\"{px:F1}\" cy=\"{py:F1}\" r=\"4\" fill=\"{ds.Color}\" stroke=\"white\" stroke-width=\"2\"/>");
}
sb.AppendLine("</svg>");
return sb.ToString();
}
// ─── Pie / Donut Chart (SVG) ─────────────────────────────────────────
private static string RenderPieChart(List<string> labels, List<Dataset> datasets, bool isDonut)
{
var values = datasets.Count > 0 ? datasets[0].Values : new List<double>();
var total = values.Sum();
if (total <= 0) total = 1;
int cx = 150, cy = 150, r = 120;
var sb = new StringBuilder();
sb.AppendLine($"<div style=\"display:flex;align-items:center;gap:24px;flex-wrap:wrap;\">");
sb.AppendLine($"<svg viewBox=\"0 0 300 300\" width=\"260\" height=\"260\">");
double startAngle = -90;
for (int i = 0; i < Math.Min(values.Count, labels.Count); i++)
{
var pct = values[i] / total;
var angle = pct * 360;
var endAngle = startAngle + angle;
var x1 = cx + r * Math.Cos(startAngle * Math.PI / 180);
var y1 = cy + r * Math.Sin(startAngle * Math.PI / 180);
var x2 = cx + r * Math.Cos(endAngle * Math.PI / 180);
var y2 = cy + r * Math.Sin(endAngle * Math.PI / 180);
var largeArc = angle > 180 ? 1 : 0;
var color = i < Palette.Length ? Palette[i] : Palette[i % Palette.Length];
sb.AppendLine($"<path d=\"M{cx},{cy} L{x1:F1},{y1:F1} A{r},{r} 0 {largeArc},1 {x2:F1},{y2:F1} Z\" fill=\"{color}\"/>");
startAngle = endAngle;
}
if (isDonut)
sb.AppendLine($"<circle cx=\"{cx}\" cy=\"{cy}\" r=\"{r * 0.55}\" fill=\"white\"/>");
sb.AppendLine("</svg>");
// 범례
sb.AppendLine("<div class=\"pie-legend\">");
for (int i = 0; i < Math.Min(values.Count, labels.Count); i++)
{
var color = i < Palette.Length ? Palette[i] : Palette[i % Palette.Length];
var pct = values[i] / total * 100;
sb.AppendLine($"<div class=\"pie-legend-item\"><span class=\"legend-dot\" style=\"background:{color}\"></span>{Escape(labels[i])} <span style=\"color:#6B7280;font-size:12px;\">({pct:F1}%)</span></div>");
}
sb.AppendLine("</div></div>");
return sb.ToString();
}
// ─── Progress Chart ──────────────────────────────────────────────────
private static string RenderProgressChart(List<string> labels, List<Dataset> datasets, string unit)
{
var values = datasets.Count > 0 ? datasets[0].Values : new List<double>();
var sb = new StringBuilder();
for (int i = 0; i < Math.Min(labels.Count, values.Count); i++)
{
var pct = Math.Clamp(values[i], 0, 100);
var color = i < Palette.Length ? Palette[i] : Palette[i % Palette.Length];
if (datasets.Count > 0 && !string.IsNullOrEmpty(datasets[0].Color))
color = datasets[0].Color;
sb.AppendLine($"<div style=\"margin-bottom:12px;\"><div style=\"display:flex;justify-content:space-between;margin-bottom:4px;\"><span style=\"font-size:13px;font-weight:600;\">{Escape(labels[i])}</span><span style=\"font-size:13px;color:#6B7280;\">{values[i]:G}{unit}</span></div>");
sb.AppendLine($"<div class=\"progress\"><div class=\"progress-fill\" style=\"width:{pct}%;background:{color};\"></div></div></div>");
}
return sb.ToString();
}
// ─── Comparison Chart ────────────────────────────────────────────────
private static string RenderComparisonChart(List<string> labels, List<Dataset> datasets, string unit)
{
var sb = new StringBuilder();
sb.AppendLine("<table style=\"width:100%;border-collapse:collapse;\">");
sb.AppendLine("<tr><th style=\"text-align:left;padding:8px 12px;\">항목</th>");
foreach (var ds in datasets)
sb.AppendLine($"<th style=\"text-align:center;padding:8px 12px;color:{ds.Color};\">{Escape(ds.Name)}</th>");
sb.AppendLine("</tr>");
for (int i = 0; i < labels.Count; i++)
{
sb.Append($"<tr style=\"border-top:1px solid #E5E7EB;\"><td style=\"padding:8px 12px;font-weight:500;\">{Escape(labels[i])}</td>");
foreach (var ds in datasets)
{
var val = i < ds.Values.Count ? ds.Values[i] : 0;
sb.Append($"<td style=\"text-align:center;padding:8px 12px;\">{val:G}{unit}</td>");
}
sb.AppendLine("</tr>");
}
sb.AppendLine("</table>");
return sb.ToString();
}
// ─── Radar Chart (SVG) ───────────────────────────────────────────────
private static string RenderRadarChart(List<string> labels, List<Dataset> datasets)
{
int cx = 150, cy = 150, r = 110;
var n = labels.Count;
if (n < 3) return "<p>레이더 차트는 최소 3개 항목이 필요합니다.</p>";
var sb = new StringBuilder();
sb.AppendLine($"<svg viewBox=\"0 0 300 300\" width=\"300\" height=\"300\">");
// 그리드
for (int level = 1; level <= 4; level++)
{
var lr = r * level / 4.0;
var points = string.Join(" ", Enumerable.Range(0, n).Select(i =>
{
var angle = (360.0 / n * i - 90) * Math.PI / 180;
return $"{cx + lr * Math.Cos(angle):F1},{cy + lr * Math.Sin(angle):F1}";
}));
sb.AppendLine($"<polygon points=\"{points}\" fill=\"none\" stroke=\"#E5E7EB\" stroke-width=\"1\"/>");
}
// 축선 + 라벨
for (int i = 0; i < n; i++)
{
var angle = (360.0 / n * i - 90) * Math.PI / 180;
var x = cx + r * Math.Cos(angle);
var y = cy + r * Math.Sin(angle);
sb.AppendLine($"<line x1=\"{cx}\" y1=\"{cy}\" x2=\"{x:F1}\" y2=\"{y:F1}\" stroke=\"#D1D5DB\" stroke-width=\"1\"/>");
var lx = cx + (r + 16) * Math.Cos(angle);
var ly = cy + (r + 16) * Math.Sin(angle);
sb.AppendLine($"<text x=\"{lx:F0}\" y=\"{ly + 4:F0}\" text-anchor=\"middle\" fill=\"#374151\" font-size=\"11\">{Escape(labels[i])}</text>");
}
// 데이터
var maxVal = datasets.SelectMany(d => d.Values).DefaultIfEmpty(1).Max();
if (maxVal <= 0) maxVal = 1;
foreach (var ds in datasets)
{
var points = string.Join(" ", Enumerable.Range(0, n).Select(i =>
{
var val = i < ds.Values.Count ? ds.Values[i] : 0;
var dr = r * val / maxVal;
var angle = (360.0 / n * i - 90) * Math.PI / 180;
return $"{cx + dr * Math.Cos(angle):F1},{cy + dr * Math.Sin(angle):F1}";
}));
sb.AppendLine($"<polygon points=\"{points}\" fill=\"{ds.Color}\" fill-opacity=\"0.2\" stroke=\"{ds.Color}\" stroke-width=\"2\"/>");
}
sb.AppendLine("</svg>");
return sb.ToString();
}
// ─── Helpers ─────────────────────────────────────────────────────────
private static List<string> ParseStringArray(JsonElement parent, string prop)
{
if (!parent.TryGetProperty(prop, out var arr) || arr.ValueKind != JsonValueKind.Array)
return new();
return arr.EnumerateArray().Select(e => e.GetString() ?? "").ToList();
}
private List<Dataset> ParseDatasets(JsonElement chart)
{
if (!chart.TryGetProperty("datasets", out var dsArr) || dsArr.ValueKind != JsonValueKind.Array)
{
// datasets 없으면 values 배열에서 단일 데이터셋 생성
if (chart.TryGetProperty("values", out var vals) && vals.ValueKind == JsonValueKind.Array)
{
return new()
{
new Dataset
{
Name = "Data",
Values = vals.EnumerateArray().Select(v => v.TryGetDouble(out var d) ? d : 0).ToList(),
Color = Palette[0],
}
};
}
return new();
}
var list = new List<Dataset>();
int colorIdx = 0;
foreach (var ds in dsArr.EnumerateArray())
{
var name = ds.TryGetProperty("name", out var n) ? n.GetString() ?? $"Series{colorIdx + 1}" : $"Series{colorIdx + 1}";
var color = ds.TryGetProperty("color", out var c) ? c.GetString() ?? Palette[colorIdx % Palette.Length] : Palette[colorIdx % Palette.Length];
var values = new List<double>();
if (ds.TryGetProperty("values", out var v) && v.ValueKind == JsonValueKind.Array)
values = v.EnumerateArray().Select(e => e.TryGetDouble(out var d) ? d : 0).ToList();
list.Add(new Dataset { Name = name, Values = values, Color = color });
colorIdx++;
}
return list;
}
private static string Escape(string s) =>
s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");
private static string FormatSize(long bytes) =>
bytes switch { < 1024 => $"{bytes}B", < 1048576 => $"{bytes / 1024.0:F1}KB", _ => $"{bytes / 1048576.0:F1}MB" };
private sealed class Dataset
{
public string Name { get; init; } = "";
public List<double> Values { get; init; } = new();
public string Color { get; init; } = "#4B5EFC";
}
// ─── Chart CSS ───────────────────────────────────────────────────────
private const string ChartCss = @"
/* Vertical Bar Chart */
.vbar-chart { margin: 16px 0; }
.vbar-bars { display: flex; align-items: flex-end; gap: 8px; height: 220px; padding: 0 8px; border-bottom: 2px solid #E5E7EB; }
.vbar-group { flex: 1; display: flex; gap: 3px; align-items: flex-end; position: relative; }
.vbar-bar { flex: 1; min-width: 18px; border-radius: 4px 4px 0 0; transition: opacity 0.2s; cursor: default; }
.vbar-bar:hover { opacity: 0.8; }
.vbar-label { text-align: center; font-size: 11px; color: #6B7280; margin-top: 6px; position: absolute; bottom: -24px; left: 0; right: 0; }
/* Horizontal Bar Chart */
.hbar-chart { margin: 12px 0; }
.hbar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.hbar-label { min-width: 80px; text-align: right; font-size: 12px; color: #374151; font-weight: 500; }
.hbar-track { flex: 1; height: 22px; background: #F3F4F6; border-radius: 6px; overflow: hidden; }
.hbar-fill { height: 100%; border-radius: 6px; transition: width 0.6s ease; }
.hbar-value { min-width: 50px; font-size: 12px; color: #6B7280; font-weight: 600; }
/* Line/Area Chart */
.line-chart-svg { width: 100%; max-width: 600px; height: auto; }
/* Legend */
.chart-legend { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 12px; padding-top: 8px; border-top: 1px solid #F3F4F6; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #374151; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
/* Pie Legend */
.pie-legend { display: flex; flex-direction: column; gap: 6px; }
.pie-legend-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #374151; }
";
}

View File

@@ -0,0 +1,388 @@
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 파일 시스템 체크포인트(스냅샷)를 생성/복원/삭제하는 도구.
/// 작업 폴더의 텍스트 파일을 .ax/checkpoints/ 에 백업하여 undo/rollback을 지원합니다.
/// </summary>
public class CheckpointTool : IAgentTool
{
public string Name => "checkpoint";
public string Description =>
"Create, list, or restore file system checkpoints for undo/rollback. " +
"Checkpoints capture text files in the working folder as snapshots. " +
"- action=\"create\": Create a new checkpoint (name optional)\n" +
"- action=\"list\": List all checkpoints\n" +
"- action=\"restore\": Restore files from a checkpoint (id or name required, requires user approval)\n" +
"- action=\"delete\": Delete a checkpoint (id required)";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "create | list | restore | delete",
Enum = ["create", "list", "restore", "delete"],
},
["name"] = new()
{
Type = "string",
Description = "Checkpoint name (for create/restore)",
},
["id"] = new()
{
Type = "integer",
Description = "Checkpoint ID (for restore/delete)",
},
},
Required = ["action"],
};
private const int MaxCheckpoints = 10;
private const long MaxFileSize = 5 * 1024 * 1024; // 5MB
private static readonly HashSet<string> SkipDirs = new(StringComparer.OrdinalIgnoreCase)
{
"bin", "obj", "node_modules", ".git", ".vs", ".ax", "packages",
"Debug", "Release", "TestResults", ".idea", "__pycache__",
};
private static readonly HashSet<string> TextExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".cs", ".py", ".js", ".ts", ".java", ".cpp", ".c", ".h", ".hpp",
".xml", ".json", ".yaml", ".yml", ".md", ".txt", ".csv", ".html", ".htm",
".css", ".sql", ".sh", ".bat", ".ps1", ".config", ".ini", ".xaml",
".csproj", ".sln", ".props", ".targets", ".editorconfig", ".gitignore",
".tsx", ".jsx", ".vue", ".svelte", ".scss", ".less", ".toml", ".env",
".razor", ".proto", ".graphql", ".rs", ".go", ".rb", ".php", ".swift",
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("action", out var actionEl))
return ToolResult.Fail("action이 필요합니다.");
var action = actionEl.GetString() ?? "";
if (string.IsNullOrEmpty(context.WorkFolder))
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
var checkpointDir = Path.Combine(context.WorkFolder, ".ax", "checkpoints");
return action switch
{
"create" => await CreateCheckpoint(args, context, checkpointDir, ct),
"list" => ListCheckpoints(checkpointDir),
"restore" => await RestoreCheckpoint(args, context, checkpointDir, ct),
"delete" => DeleteCheckpoint(args, checkpointDir),
_ => ToolResult.Fail($"알 수 없는 액션: {action}. create | list | restore | delete 중 선택하세요."),
};
}
private async Task<ToolResult> CreateCheckpoint(JsonElement args, AgentContext context, string checkpointDir, CancellationToken ct)
{
var name = args.TryGetProperty("name", out var n) ? n.GetString() ?? "unnamed" : "unnamed";
// 이름에서 파일 시스템 비안전 문자 제거
name = string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var folderName = $"{timestamp}_{name}";
var cpDir = Path.Combine(checkpointDir, folderName);
try
{
Directory.CreateDirectory(cpDir);
// 추적 대상 텍스트 파일 수집
var files = CollectTextFiles(context.WorkFolder);
if (files.Count == 0)
return ToolResult.Fail("체크포인트할 텍스트 파일이 없습니다.");
var manifest = new CheckpointManifest
{
Name = name,
CreatedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
WorkFolder = context.WorkFolder,
Files = [],
};
foreach (var file in files)
{
ct.ThrowIfCancellationRequested();
var relativePath = Path.GetRelativePath(context.WorkFolder, file);
var destPath = Path.Combine(cpDir, relativePath);
var destDir = Path.GetDirectoryName(destPath);
if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir))
Directory.CreateDirectory(destDir);
await CopyFileAsync(file, destPath, ct);
// SHA256 해시 계산
var hash = await ComputeHashAsync(file, ct);
manifest.Files.Add(new FileEntry { Path = relativePath, Hash = hash });
}
// manifest.json 저장
var manifestJson = JsonSerializer.Serialize(manifest, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
});
await TextFileCodec.WriteAllTextAsync(
Path.Combine(cpDir, "manifest.json"),
manifestJson,
TextFileCodec.Utf8NoBom,
ct);
// 최대 개수 초과 시 가장 오래된 체크포인트 삭제
CleanupOldCheckpoints(checkpointDir);
return ToolResult.Ok($"체크포인트 생성 완료: {folderName}\n파일 수: {manifest.Files.Count}");
}
catch (Exception ex)
{
return ToolResult.Fail($"체크포인트 생성 오류: {ex.Message}");
}
}
private static ToolResult ListCheckpoints(string checkpointDir)
{
if (!Directory.Exists(checkpointDir))
return ToolResult.Ok("저장된 체크포인트가 없습니다.");
var dirs = Directory.GetDirectories(checkpointDir)
.OrderByDescending(d => d)
.ToList();
if (dirs.Count == 0)
return ToolResult.Ok("저장된 체크포인트가 없습니다.");
var sb = new StringBuilder();
sb.AppendLine($"체크포인트 {dirs.Count}개:");
for (var i = 0; i < dirs.Count; i++)
{
var dirName = Path.GetFileName(dirs[i]);
var manifestPath = Path.Combine(dirs[i], "manifest.json");
var fileCount = 0;
var createdAt = "";
if (File.Exists(manifestPath))
{
try
{
var json = TextFileCodec.ReadAllText(manifestPath).Text;
var manifest = JsonSerializer.Deserialize<CheckpointManifest>(json);
fileCount = manifest?.Files.Count ?? 0;
createdAt = manifest?.CreatedAt ?? "";
}
catch { /* manifest 읽기 실패 무시 */ }
}
sb.AppendLine($" [{i}] {dirName} — 파일 {fileCount}개{(string.IsNullOrEmpty(createdAt) ? "" : $", {createdAt}")}");
}
return ToolResult.Ok(sb.ToString());
}
private async Task<ToolResult> RestoreCheckpoint(JsonElement args, AgentContext context, string checkpointDir, CancellationToken ct)
{
if (!Directory.Exists(checkpointDir))
return ToolResult.Fail("저장된 체크포인트가 없습니다.");
var dirs = Directory.GetDirectories(checkpointDir)
.OrderByDescending(d => d)
.ToList();
if (dirs.Count == 0)
return ToolResult.Fail("저장된 체크포인트가 없습니다.");
// ID 또는 이름으로 체크포인트 찾기
string? targetDir = null;
if (args.TryGetProperty("id", out var idEl))
{
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
if (id >= 0 && id < dirs.Count)
targetDir = dirs[id];
}
if (targetDir == null && args.TryGetProperty("name", out var nameEl))
{
var name = nameEl.GetString() ?? "";
targetDir = dirs.FirstOrDefault(d => Path.GetFileName(d).Contains(name, StringComparison.OrdinalIgnoreCase));
}
if (targetDir == null)
return ToolResult.Fail("체크포인트를 찾을 수 없습니다. id 또는 name을 확인하세요.");
// 사용자 승인 요청
if (context.AskPermission != null)
{
var approved = await context.AskPermission("checkpoint_restore", $"체크포인트 복원: {Path.GetFileName(targetDir)}");
if (!approved)
return ToolResult.Ok("사용자가 복원을 거부했습니다.");
}
try
{
var manifestPath = Path.Combine(targetDir, "manifest.json");
if (!File.Exists(manifestPath))
return ToolResult.Fail("체크포인트 매니페스트를 찾을 수 없습니다.");
var manifestJson = (await TextFileCodec.ReadAllTextAsync(manifestPath, ct)).Text;
var manifest = JsonSerializer.Deserialize<CheckpointManifest>(manifestJson);
if (manifest == null)
return ToolResult.Fail("매니페스트 파싱 오류");
var restoredCount = 0;
foreach (var entry in manifest.Files)
{
ct.ThrowIfCancellationRequested();
var srcPath = Path.Combine(targetDir, entry.Path);
var destPath = Path.Combine(context.WorkFolder, entry.Path);
if (!File.Exists(srcPath)) continue;
var destDir = Path.GetDirectoryName(destPath);
if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir))
Directory.CreateDirectory(destDir);
await CopyFileAsync(srcPath, destPath, ct);
restoredCount++;
}
return ToolResult.Ok($"체크포인트 복원 완료: {Path.GetFileName(targetDir)}\n복원 파일 수: {restoredCount}/{manifest.Files.Count}");
}
catch (Exception ex)
{
return ToolResult.Fail($"체크포인트 복원 오류: {ex.Message}");
}
}
private static ToolResult DeleteCheckpoint(JsonElement args, string checkpointDir)
{
if (!Directory.Exists(checkpointDir))
return ToolResult.Fail("저장된 체크포인트가 없습니다.");
var dirs = Directory.GetDirectories(checkpointDir)
.OrderByDescending(d => d)
.ToList();
if (!args.TryGetProperty("id", out var idEl))
return ToolResult.Fail("삭제할 체크포인트 id가 필요합니다.");
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
if (id < 0 || id >= dirs.Count)
return ToolResult.Fail($"잘못된 체크포인트 ID: {id}. 0~{dirs.Count - 1} 범위를 사용하세요.");
try
{
var target = dirs[id];
var name = Path.GetFileName(target);
Directory.Delete(target, recursive: true);
return ToolResult.Ok($"체크포인트 삭제됨: {name}");
}
catch (Exception ex)
{
return ToolResult.Fail($"체크포인트 삭제 오류: {ex.Message}");
}
}
#region Helpers
private List<string> CollectTextFiles(string workFolder)
{
var files = new List<string>();
CollectFilesRecursive(workFolder, workFolder, files);
return files;
}
private void CollectFilesRecursive(string dir, string rootDir, List<string> files)
{
var dirName = Path.GetFileName(dir);
if (dir != rootDir && SkipDirs.Contains(dirName))
return;
try
{
foreach (var file in Directory.GetFiles(dir))
{
var ext = Path.GetExtension(file);
if (!TextExtensions.Contains(ext)) continue;
var fi = new FileInfo(file);
if (fi.Length > MaxFileSize) continue;
files.Add(file);
}
foreach (var subDir in Directory.GetDirectories(dir))
CollectFilesRecursive(subDir, rootDir, files);
}
catch (UnauthorizedAccessException) { /* 접근 불가 디렉토리 무시 */ }
}
private static async Task CopyFileAsync(string src, string dest, CancellationToken ct)
{
var buffer = new byte[81920];
await using var srcStream = new FileStream(src, FileMode.Open, FileAccess.Read, FileShare.Read, buffer.Length, useAsync: true);
await using var destStream = new FileStream(dest, FileMode.Create, FileAccess.Write, FileShare.None, buffer.Length, useAsync: true);
int bytesRead;
while ((bytesRead = await srcStream.ReadAsync(buffer, ct)) > 0)
await destStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct);
}
private static async Task<string> ComputeHashAsync(string filePath, CancellationToken ct)
{
using var sha256 = SHA256.Create();
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true);
var hash = await sha256.ComputeHashAsync(stream, ct);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static void CleanupOldCheckpoints(string checkpointDir)
{
if (!Directory.Exists(checkpointDir)) return;
var dirs = Directory.GetDirectories(checkpointDir)
.OrderBy(d => d)
.ToList();
while (dirs.Count > MaxCheckpoints)
{
try
{
Directory.Delete(dirs[0], recursive: true);
dirs.RemoveAt(0);
}
catch { break; }
}
}
#endregion
#region Models
private class CheckpointManifest
{
public string Name { get; set; } = "";
public string CreatedAt { get; set; } = "";
public string WorkFolder { get; set; } = "";
public List<FileEntry> Files { get; set; } = [];
}
private class FileEntry
{
public string Path { get; set; } = "";
public string Hash { get; set; } = "";
}
#endregion
}

View File

@@ -0,0 +1,90 @@
using System.Text.Json;
using System.Windows;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Windows 클립보드 읽기·쓰기 도구.
/// 에이전트가 클립보드를 통해 데이터를 주고받을 수 있게 합니다.
/// </summary>
public class ClipboardTool : IAgentTool
{
public string Name => "clipboard_tool";
public string Description =>
"Read or write the Windows clipboard. Actions: " +
"'read' — get current clipboard text content; " +
"'write' — set clipboard text content; " +
"'has_text' — check if clipboard contains text; " +
"'has_image' — check if clipboard contains an image.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "Action to perform",
Enum = ["read", "write", "has_text", "has_image"],
},
["text"] = new()
{
Type = "string",
Description = "Text to write to clipboard (required for 'write' action)",
},
},
Required = ["action"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
try
{
// 클립보드는 STA 스레드에서만 접근 가능
ToolResult? result = null;
Application.Current.Dispatcher.Invoke(() =>
{
result = action switch
{
"read" => ReadClipboard(),
"write" => WriteClipboard(args),
"has_text" => ToolResult.Ok(Clipboard.ContainsText() ? "true" : "false"),
"has_image" => ToolResult.Ok(Clipboard.ContainsImage() ? "true" : "false"),
_ => ToolResult.Fail($"Unknown action: {action}"),
};
});
return Task.FromResult(result ?? ToolResult.Fail("클립보드 접근 실패"));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"클립보드 오류: {ex.Message}"));
}
}
private static ToolResult ReadClipboard()
{
if (!Clipboard.ContainsText())
return ToolResult.Ok("(clipboard is empty or contains non-text data)");
var text = Clipboard.GetText();
if (string.IsNullOrEmpty(text))
return ToolResult.Ok("(empty)");
if (text.Length > 10000)
return ToolResult.Ok(text[..10000] + $"\n\n... (truncated, total {text.Length} chars)");
return ToolResult.Ok(text);
}
private static ToolResult WriteClipboard(JsonElement args)
{
if (!args.TryGetProperty("text", out var textProp))
return ToolResult.Fail("'text' parameter is required for write action");
var text = textProp.GetString() ?? "";
Clipboard.SetText(text);
return ToolResult.Ok($"✓ Clipboard updated ({text.Length} chars)");
}
}

View File

@@ -0,0 +1,429 @@
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>
/// AI 코드 리뷰 도구.
/// Git diff 분석, 파일 정적 검사, PR 요약 생성을 지원합니다.
/// LLM이 구조화된 분석 결과를 바탕으로 상세한 리뷰를 작성합니다.
/// </summary>
public class CodeReviewTool : IAgentTool
{
public string Name => "code_review";
public string Description =>
"코드 리뷰를 수행합니다. Git diff 분석, 파일 정적 검사, PR 요약을 생성합니다.\n" +
"action별 기능:\n" +
"- diff_review: git diff 출력을 분석하여 이슈/개선점을 구조화\n" +
"- file_review: 특정 파일의 코드 품질을 정적 검사\n" +
"- pr_summary: 변경사항을 PR 설명 형식으로 요약";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new ToolProperty
{
Type = "string",
Description = "리뷰 유형: diff_review (diff 분석), file_review (파일 검사), pr_summary (PR 요약)",
Enum = ["diff_review", "file_review", "pr_summary"]
},
["target"] = new ToolProperty
{
Type = "string",
Description = "대상 지정. diff_review: '--staged' 또는 빈값(working tree). file_review: 파일 경로. pr_summary: 브랜치명(선택)."
},
["focus"] = new ToolProperty
{
Type = "string",
Description = "리뷰 초점: all (전체), bugs (버그), performance (성능), security (보안), style (스타일). 기본 all.",
Enum = ["all", "bugs", "performance", "security", "style"]
},
},
Required = ["action"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var app = System.Windows.Application.Current as App;
if (!(app?.SettingsService?.Settings.Llm.Code.EnableCodeReview ?? true))
return ToolResult.Ok("코드 리뷰가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
var target = args.TryGetProperty("target", out var t) ? t.GetString() ?? "" : "";
var focus = args.TryGetProperty("focus", out var f) ? f.GetString() ?? "all" : "all";
if (string.IsNullOrEmpty(context.WorkFolder))
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
return action switch
{
"diff_review" => await DiffReviewAsync(context, target, focus, ct),
"file_review" => await FileReviewAsync(context, target, focus, ct),
"pr_summary" => await PrSummaryAsync(context, target, ct),
_ => ToolResult.Fail($"지원하지 않는 action: {action}. diff_review, file_review, pr_summary 중 선택하세요.")
};
}
// ─── diff_review ─────────────────────────────────────────────────────────
private async Task<ToolResult> DiffReviewAsync(AgentContext ctx, string target, string focus, CancellationToken ct)
{
var diffArgs = string.IsNullOrEmpty(target) ? "diff" : $"diff {target}";
var diffResult = await RunGitAsync(ctx.WorkFolder, diffArgs, ct);
if (diffResult == null) return ToolResult.Fail("Git을 찾을 수 없습니다.");
if (string.IsNullOrWhiteSpace(diffResult))
return ToolResult.Ok("변경사항이 없습니다. (clean working tree)");
// diff 파싱
var files = ParseDiffFiles(diffResult);
var sb = new StringBuilder();
sb.AppendLine("═══ Code Review Report (diff_review) ═══\n");
// 통계
int totalAdded = 0, totalRemoved = 0;
foreach (var file in files)
{
totalAdded += file.Added;
totalRemoved += file.Removed;
}
sb.AppendLine($"📊 파일 {files.Count}개 변경 | +{totalAdded} 추가 -{totalRemoved} 삭제\n");
sb.AppendLine($"🔍 리뷰 초점: {focus}\n");
foreach (var file in files)
{
sb.AppendLine($"─── {file.Path} ({file.Status}) ───");
sb.AppendLine($" 변경: +{file.Added} -{file.Removed}");
// 정적 패턴 검사
var issues = AnalyzeDiffHunks(file.Hunks, focus);
if (issues.Count > 0)
{
foreach (var issue in issues)
sb.AppendLine($" [{issue.Severity}] Line {issue.Line}: {issue.Message}");
}
else
{
sb.AppendLine(" [OK] 정적 검사에서 특이사항 없음");
}
sb.AppendLine();
}
sb.AppendLine("═══ 위 분석 결과를 바탕으로 상세한 코드 리뷰를 작성하세요. ═══");
return ToolResult.Ok(sb.ToString());
}
// ─── file_review ─────────────────────────────────────────────────────────
private async Task<ToolResult> FileReviewAsync(AgentContext ctx, string target, string focus, CancellationToken ct)
{
if (string.IsNullOrEmpty(target))
return ToolResult.Fail("file_review에는 target(파일 경로)이 필요합니다.");
var fullPath = Path.IsPathRooted(target) ? target : Path.Combine(ctx.WorkFolder, target);
if (!File.Exists(fullPath))
return ToolResult.Fail($"파일을 찾을 수 없습니다: {target}");
if (!ctx.IsPathAllowed(fullPath))
return ToolResult.Fail($"접근이 차단된 경로입니다: {target}");
var content = await File.ReadAllTextAsync(fullPath, ct);
var lines = content.Split('\n');
var sb = new StringBuilder();
sb.AppendLine($"═══ Code Review Report (file_review) ═══\n");
sb.AppendLine($"📁 파일: {target}");
sb.AppendLine($"📏 {lines.Length}줄 | 🔍 초점: {focus}\n");
var issues = AnalyzeFile(lines, focus);
if (issues.Count > 0)
{
sb.AppendLine($"⚠️ 발견된 이슈 {issues.Count}개:\n");
foreach (var issue in issues)
sb.AppendLine($" [{issue.Severity}] Line {issue.Line}: {issue.Message}");
}
else
{
sb.AppendLine("✅ 정적 검사에서 특이사항 없음");
}
// 파일 앞부분 제공 (LLM이 상세 리뷰할 수 있도록)
sb.AppendLine($"\n─── 파일 내용 (처음 200줄) ───");
var preview = string.Join('\n', lines.Take(200));
sb.AppendLine(preview);
if (lines.Length > 200)
sb.AppendLine($"\n... ({lines.Length - 200}줄 생략)");
sb.AppendLine("\n═══ 위 분석 결과와 코드를 바탕으로 상세한 리뷰를 작성하세요. ═══");
return ToolResult.Ok(sb.ToString());
}
// ─── pr_summary ──────────────────────────────────────────────────────────
private async Task<ToolResult> PrSummaryAsync(AgentContext ctx, string target, CancellationToken ct)
{
var sb = new StringBuilder();
sb.AppendLine("═══ PR Summary Data ═══\n");
// git log
var logArgs = string.IsNullOrEmpty(target)
? "log --oneline -20"
: $"log --oneline {target}..HEAD";
var log = await RunGitAsync(ctx.WorkFolder, logArgs, ct);
if (!string.IsNullOrWhiteSpace(log))
{
sb.AppendLine("📋 커밋 이력:");
sb.AppendLine(log);
sb.AppendLine();
}
// git diff --stat
var statArgs = string.IsNullOrEmpty(target) ? "diff --stat" : $"diff --stat {target}..HEAD";
var stat = await RunGitAsync(ctx.WorkFolder, statArgs, ct);
if (!string.IsNullOrWhiteSpace(stat))
{
sb.AppendLine("📊 변경 통계:");
sb.AppendLine(stat);
sb.AppendLine();
}
// git status
var status = await RunGitAsync(ctx.WorkFolder, "status --short", ct);
if (!string.IsNullOrWhiteSpace(status))
{
sb.AppendLine("📁 현재 상태:");
sb.AppendLine(status);
sb.AppendLine();
}
sb.AppendLine("═══ 위 데이터를 바탕으로 PR 제목과 설명(Summary, Changes, Test Plan)을 작성하세요. ═══");
return ToolResult.Ok(sb.ToString());
}
// ─── 정적 분석 ──────────────────────────────────────────────────────────
private static List<ReviewIssue> AnalyzeDiffHunks(List<string> hunks, string focus)
{
var issues = new List<ReviewIssue>();
int lineNum = 0;
foreach (var line in hunks)
{
// @@ -a,b +c,d @@ 에서 라인 번호 추출
var hunkMatch = Regex.Match(line, @"@@ -\d+(?:,\d+)? \+(\d+)");
if (hunkMatch.Success)
{
lineNum = int.Parse(hunkMatch.Groups[1].Value);
continue;
}
if (!line.StartsWith('+') || line.StartsWith("+++")) continue;
lineNum++;
var content = line[1..];
if (focus is "all" or "bugs")
{
if (Regex.IsMatch(content, @"catch\s*\{?\s*\}"))
issues.Add(new(lineNum, "WARNING", "빈 catch 블록 — 예외가 무시됩니다"));
if (Regex.IsMatch(content, @"\.Result\b|\.Wait\(\)"))
issues.Add(new(lineNum, "WARNING", "동기 대기 (.Result/.Wait()) — 데드락 위험"));
if (Regex.IsMatch(content, @"==\s*null") && content.Contains('.'))
issues.Add(new(lineNum, "INFO", "null 비교 — null 조건 연산자(?.) 사용 검토"));
}
if (focus is "all" or "security")
{
if (Regex.IsMatch(content, @"(password|secret|token|api_?key)\s*=\s*""[^""]+""", RegexOptions.IgnoreCase))
issues.Add(new(lineNum, "CRITICAL", "하드코딩된 비밀번호/키 감지"));
if (Regex.IsMatch(content, @"(TODO|FIXME|HACK|XXX)\b", RegexOptions.IgnoreCase))
issues.Add(new(lineNum, "INFO", $"TODO/FIXME 마커 발견: {content.Trim()}"));
}
if (focus is "all" or "performance")
{
if (Regex.IsMatch(content, @"new\s+List<.*>\(\).*\.Add\(") || Regex.IsMatch(content, @"\.ToList\(\).*\.Where\("))
issues.Add(new(lineNum, "INFO", "불필요한 컬렉션 할당 가능성"));
if (Regex.IsMatch(content, @"string\s*\+\s*=|"".*""\s*\+\s*"""))
issues.Add(new(lineNum, "INFO", "문자열 연결 — StringBuilder 사용 검토"));
}
if (focus is "all" or "style")
{
if (content.Length > 150)
issues.Add(new(lineNum, "STYLE", $"긴 라인 ({content.Length}자) — 가독성 저하"));
}
}
return issues;
}
private static List<ReviewIssue> AnalyzeFile(string[] lines, string focus)
{
var issues = new List<ReviewIssue>();
// 메서드 길이 추정 (중괄호 카운팅)
int braceDepth = 0, methodStart = 0;
bool inMethod = false;
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i];
var lineNum = i + 1;
var trimmed = line.TrimStart();
// 메서드 시작 감지 (간단 휴리스틱)
if (Regex.IsMatch(trimmed, @"(public|private|protected|internal|static|async|override)\s+.*\(.*\)\s*\{?\s*$") && !trimmed.Contains(';'))
{
inMethod = true;
methodStart = lineNum;
}
if (trimmed.Contains('{')) braceDepth++;
if (trimmed.Contains('}'))
{
braceDepth--;
if (inMethod && braceDepth <= 1)
{
var methodLen = lineNum - methodStart;
if (methodLen > 60 && (focus is "all" or "style"))
issues.Add(new(methodStart, "STYLE", $"긴 메서드 ({methodLen}줄) — 분할 검토"));
inMethod = false;
}
}
if (focus is "all" or "bugs")
{
if (Regex.IsMatch(trimmed, @"catch\s*(\(\s*Exception)?\s*\)?\s*\{\s*\}"))
issues.Add(new(lineNum, "WARNING", "빈 catch 블록"));
if (Regex.IsMatch(trimmed, @"\.Result\b|\.Wait\(\)"))
issues.Add(new(lineNum, "WARNING", "동기 대기 (.Result/.Wait()) — 데드락 위험"));
}
if (focus is "all" or "security")
{
if (Regex.IsMatch(trimmed, @"(password|secret|token|api_?key)\s*=\s*""[^""]+""", RegexOptions.IgnoreCase))
issues.Add(new(lineNum, "CRITICAL", "하드코딩된 비밀번호/키"));
if (Regex.IsMatch(trimmed, @"(TODO|FIXME|HACK|XXX)\b", RegexOptions.IgnoreCase))
issues.Add(new(lineNum, "INFO", $"마커: {trimmed.Trim()}"));
}
if (focus is "all" or "performance")
{
if (Regex.IsMatch(trimmed, @"string\s*\+\s*="))
issues.Add(new(lineNum, "INFO", "루프 내 문자열 연결 — StringBuilder 검토"));
}
if (focus is "all" or "style")
{
if (line.Length > 150)
issues.Add(new(lineNum, "STYLE", $"긴 라인 ({line.Length}자)"));
}
}
// 파일 전체 크기
if (lines.Length > 500 && (focus is "all" or "style"))
issues.Add(new(1, "STYLE", $"큰 파일 ({lines.Length}줄) — 클래스 분할 검토"));
return issues;
}
// ─── Git 실행 헬퍼 ──────────────────────────────────────────────────────
private static async Task<string?> RunGitAsync(string workDir, string args, CancellationToken ct)
{
var gitPath = FindGit();
if (gitPath == null) return null;
try
{
var psi = new ProcessStartInfo(gitPath, args)
{
WorkingDirectory = workDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(30));
using var proc = Process.Start(psi);
if (proc == null) return null;
var stdout = await proc.StandardOutput.ReadToEndAsync(cts.Token);
await proc.WaitForExitAsync(cts.Token);
return stdout.Length > 12000 ? stdout[..12000] + "\n... (출력 잘림)" : stdout;
}
catch { return null; }
}
private static string? FindGit()
{
var paths = new[] { "git", @"C:\Program Files\Git\bin\git.exe", @"C:\Program Files (x86)\Git\bin\git.exe" };
foreach (var p in paths)
{
try
{
var psi = new ProcessStartInfo(p, "--version")
{ RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true };
using var proc = Process.Start(psi);
proc?.WaitForExit(3000);
if (proc?.ExitCode == 0) return p;
}
catch { }
}
return null;
}
// ─── Diff 파서 ──────────────────────────────────────────────────────────
private static List<DiffFile> ParseDiffFiles(string diff)
{
var files = new List<DiffFile>();
DiffFile? current = null;
foreach (var line in diff.Split('\n'))
{
if (line.StartsWith("diff --git"))
{
current = new DiffFile();
files.Add(current);
// "diff --git a/path b/path" → path 추출
var parts = line.Split(" b/");
current.Path = parts.Length > 1 ? parts[1].Trim() : line;
}
else if (current != null)
{
if (line.StartsWith("new file")) current.Status = "added";
else if (line.StartsWith("deleted file")) current.Status = "deleted";
else if (line.StartsWith("@@") || line.StartsWith("+") || line.StartsWith("-"))
{
current.Hunks.Add(line);
if (line.StartsWith("+") && !line.StartsWith("+++")) current.Added++;
if (line.StartsWith("-") && !line.StartsWith("---")) current.Removed++;
}
}
}
return files;
}
private class DiffFile
{
public string Path { get; set; } = "";
public string Status { get; set; } = "modified";
public int Added { get; set; }
public int Removed { get; set; }
public List<string> Hunks { get; } = new();
}
private record ReviewIssue(int Line, string Severity, string Message);
}

View File

@@ -0,0 +1,108 @@
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 코드베이스 시맨틱 검색 도구.
/// 프로젝트 파일을 TF-IDF로 인덱싱하고, 자연어 질문으로 관련 코드를 찾습니다.
/// SQLite에 인덱스를 영속 저장하여 증분 업데이트와 빠른 재시작을 지원합니다.
/// </summary>
public class CodeSearchTool : IAgentTool
{
public string Name => "search_codebase";
public string Description =>
"코드베이스를 시맨틱 검색합니다. 자연어 질문으로 관련 있는 코드를 찾습니다.\n" +
"예: '사용자 인증 로직', '데이터베이스 연결 설정', '에러 핸들링 패턴'\n" +
"인덱스는 영속 저장되어 변경된 파일만 증분 업데이트합니다.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["query"] = new ToolProperty
{
Type = "string",
Description = "검색할 내용 (자연어 또는 키워드)"
},
["max_results"] = new ToolProperty
{
Type = "integer",
Description = "최대 결과 수 (기본 5)"
},
["reindex"] = new ToolProperty
{
Type = "boolean",
Description = "true면 기존 인덱스를 버리고 전체 재인덱싱 (기본 false)"
},
},
Required = new() { "query" }
};
private static CodeIndexService? _indexService;
private static string _lastWorkFolder = "";
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
// 설정 체크
var app = System.Windows.Application.Current as App;
if (!(app?.SettingsService?.Settings.Llm.Code.EnableCodeIndex ?? true))
return ToolResult.Ok("코드 시맨틱 검색이 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
var maxResults = args.TryGetProperty("max_results", out var m) ? m.GetInt32() : 5;
var reindex = args.TryGetProperty("reindex", out var ri) && ri.GetBoolean();
if (string.IsNullOrWhiteSpace(query))
return ToolResult.Fail("query가 필요합니다.");
if (string.IsNullOrEmpty(context.WorkFolder))
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
// 작업 폴더가 바뀌면 인덱스 서비스 재생성
if (_indexService == null || _lastWorkFolder != context.WorkFolder || reindex)
{
_indexService?.Dispose();
_indexService = new CodeIndexService();
_lastWorkFolder = context.WorkFolder;
// 기존 sqlite 인덱스 로드 시도 (앱 재시작 시 즉시 사용 가능)
if (!reindex)
_indexService.TryLoadExisting(context.WorkFolder);
}
// 증분 인덱싱 (신규/변경 파일만 처리)
if (!_indexService.IsIndexed || reindex)
{
await _indexService.IndexAsync(context.WorkFolder, ct);
if (!_indexService.IsIndexed)
return ToolResult.Fail("코드 인덱싱에 실패했습니다.");
}
var results = _indexService.Search(query, maxResults);
if (results.Count == 0)
return ToolResult.Ok($"'{query}'에 대한 관련 코드를 찾지 못했습니다. 다른 키워드로 검색해보세요.");
var sb = new StringBuilder();
sb.AppendLine($"'{query}' 검색 결과 ({results.Count}개, 인덱스 {_indexService.ChunkCount}개 청크):\n");
foreach (var r in results)
{
sb.AppendLine($"📁 {r.FilePath} (line {r.StartLine}-{r.EndLine}, score {r.Score:F3})");
sb.AppendLine("```");
sb.AppendLine(r.Preview);
sb.AppendLine("```");
sb.AppendLine();
}
return ToolResult.Ok(sb.ToString());
}
/// <summary>인덱스를 강제 재빌드합니다.</summary>
public static void InvalidateIndex()
{
_indexService?.Dispose();
_indexService = null;
_lastWorkFolder = "";
}
}

View File

@@ -0,0 +1,250 @@
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 컨텍스트 윈도우 관리: 대화가 길어지면 이전 메시지를 요약하여 압축합니다.
/// MemGPT/OpenHands의 LLMSummarizingCondenser 패턴을 경량 구현.
///
/// 2단계 압축 전략:
/// 1단계 — 도구 결과 자르기: 대용량 tool_result 출력을 핵심만 남기고 축약 (LLM 호출 없음)
/// 2단계 — 이전 대화 요약: 오래된 메시지를 LLM으로 요약하여 교체 (LLM 1회 호출)
/// </summary>
public static class ContextCondenser
{
/// <summary>도구 결과 1개당 최대 유지 길이 (자)</summary>
private const int MaxToolResultChars = 1500;
/// <summary>요약 시 유지할 최근 메시지 수</summary>
private const int RecentKeepCount = 6;
/// <summary>모델별 입력 토큰 한도 (대략). 정확한 값은 중요하지 않음 — 안전 마진으로 70% 적용.</summary>
private static int GetModelInputLimit(string service, string model)
{
var key = $"{service}:{model}".ToLowerInvariant();
return key switch
{
_ when key.Contains(string.Concat("cl", "aude")) => 180_000, // Claude 계열 200K
_ when key.Contains("gemini-2.5") => 900_000, // Gemini 1M
_ when key.Contains("gemini-2.0") => 900_000,
_ when key.Contains("gemini") => 900_000,
_ when key.Contains("gpt-4") => 120_000, // GPT-4 128K
_ => 16_000, // Ollama/vLLM 로컬 모델 기본값
};
}
/// <summary>
/// 메시지 목록의 토큰이 모델 한도에 근접하면 자동 압축합니다.
/// 1단계: 도구 결과 축약 (빠르고 LLM 호출 없음)
/// 2단계: 이전 대화 LLM 요약 (토큰이 여전히 높으면)
/// </summary>
public static async Task<bool> CondenseIfNeededAsync(
List<ChatMessage> messages, LlmService llm, int maxOutputTokens, CancellationToken ct = default)
{
if (messages.Count < 6) return false;
// 현재 모델의 입력 토큰 한도
var settings = llm.GetCurrentModelInfo();
var inputLimit = GetModelInputLimit(settings.service, settings.model);
var threshold = (int)(inputLimit * 0.65); // 65%에서 압축 시작
var currentTokens = TokenEstimator.EstimateMessages(messages);
if (currentTokens < threshold) return false;
bool didCompress = false;
// ── 1단계: 도구 결과 축약 (LLM 호출 없음, 즉시 실행) ──
didCompress |= TruncateToolResults(messages);
// 1단계 후 다시 추정
currentTokens = TokenEstimator.EstimateMessages(messages);
if (currentTokens < threshold) return didCompress;
// ── 2단계: 이전 대화 LLM 요약 ──
didCompress |= await SummarizeOldMessagesAsync(messages, llm, ct);
if (didCompress)
{
var afterTokens = TokenEstimator.EstimateMessages(messages);
LogService.Info($"Context Condenser: {currentTokens} → {afterTokens} 토큰 (절감 {currentTokens - afterTokens})");
}
return didCompress;
}
/// <summary>
/// 1단계: 대용량 도구 결과를 축약합니다.
/// tool_result JSON이나 긴 파일 내용 등을 핵심만 남기고 자릅니다.
/// </summary>
private static bool TruncateToolResults(List<ChatMessage> messages)
{
bool truncated = false;
// 최근 RecentKeepCount개는 건드리지 않음 (방금 실행한 도구 결과는 유지)
var cutoff = Math.Max(0, messages.Count - RecentKeepCount);
for (int i = 0; i < cutoff; i++)
{
var msg = messages[i];
if (msg.Content == null) continue;
// tool_result 메시지의 대용량 출력 축약
if (msg.Content.StartsWith("{\"type\":\"tool_result\"") && msg.Content.Length > MaxToolResultChars)
{
// JSON 구조를 유지하되 output 부분만 축약
messages[i] = CloneWithContent(msg, TruncateToolResultJson(msg.Content));
truncated = true;
}
// assistant의 도구 호출 블록 내 긴 텍스트도 축약
else if (msg.Role == "assistant" && msg.Content.Length > MaxToolResultChars * 2 &&
msg.Content.StartsWith("{\"_tool_use_blocks\""))
{
// 도구 호출 구조는 유지, 텍스트 블록만 축약
if (msg.Content.Length > MaxToolResultChars * 3)
{
messages[i] = CloneWithContent(msg, msg.Content[..(MaxToolResultChars * 2)] + "...[축약됨]\"]}");
truncated = true;
}
}
// 일반 assistant/user 메시지 중 비정상적으로 긴 것 (예: 파일 내용 전체 붙여넣기)
else if (msg.Content.Length > MaxToolResultChars * 3 && msg.Role != "system")
{
messages[i] = CloneWithContent(
msg,
msg.Content[..MaxToolResultChars] + "\n\n...[이전 내용 축약됨 — 원본 " +
$"{msg.Content.Length:N0}자 중 {MaxToolResultChars:N0}자 유지]");
truncated = true;
}
}
return truncated;
}
private static ChatMessage CloneWithContent(ChatMessage source, string content)
{
return new ChatMessage
{
Role = source.Role,
Content = content,
Timestamp = source.Timestamp,
MetaKind = source.MetaKind,
MetaRunId = source.MetaRunId,
Feedback = source.Feedback,
AttachedFiles = source.AttachedFiles?.ToList(),
Images = source.Images?.Select(x => new ImageAttachment
{
Base64 = x.Base64,
MimeType = x.MimeType,
FileName = x.FileName,
}).ToList(),
};
}
/// <summary>tool_result JSON 내의 output 값을 축약합니다.</summary>
private static string TruncateToolResultJson(string json)
{
// 간단한 문자열 처리로 output 부분만 축약 (JSON 파서 없이)
const string marker = "\"output\":\"";
var idx = json.IndexOf(marker, StringComparison.Ordinal);
if (idx < 0) return json[..Math.Min(json.Length, MaxToolResultChars)] + "...[축약됨]}";
var outputStart = idx + marker.Length;
// output 끝 찾기 (이스케이프된 따옴표 고려)
var outputEnd = outputStart;
while (outputEnd < json.Length)
{
if (json[outputEnd] == '\\') { outputEnd += 2; continue; }
if (json[outputEnd] == '"') break;
outputEnd++;
}
var outputLen = outputEnd - outputStart;
if (outputLen <= MaxToolResultChars) return json; // 이미 짧음
// 앞부분 + "...[축약됨]" + 뒷부분
var keepLen = MaxToolResultChars / 2;
var prefix = json[..outputStart];
var outputText = json[outputStart..outputEnd];
var suffix = json[outputEnd..];
return prefix +
outputText[..keepLen] +
"\\n...[축약됨: " + $"{outputLen:N0}" + "자 중 " + $"{MaxToolResultChars:N0}" + "자 유지]\\n" +
outputText[^keepLen..] +
suffix;
}
/// <summary>
/// 2단계: 오래된 메시지를 LLM으로 요약합니다.
/// 시스템 메시지 + 최근 N개는 유지하고, 나머지를 요약으로 교체합니다.
/// </summary>
private static async Task<bool> SummarizeOldMessagesAsync(
List<ChatMessage> messages, LlmService llm, CancellationToken ct)
{
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
var systemCount = systemMsg != null ? 1 : 0;
var nonSystemMessages = messages.Where(m => m.Role != "system").ToList();
var keepCount = Math.Min(RecentKeepCount, nonSystemMessages.Count);
var recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
var oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
if (oldMessages.Count < 3) return false;
// 요약 대상 텍스트 구성
var sb = new System.Text.StringBuilder();
sb.AppendLine("다음 대화 기록을 간결하게 요약하세요.");
sb.AppendLine("반드시 유지할 정보: 사용자 요청, 핵심 결정 사항, 생성/수정된 파일 경로, 작업 진행 상황, 중요한 결과.");
sb.AppendLine("제거할 정보: 도구 실행의 상세 출력, 반복되는 내용, 중간 사고 과정.");
sb.AppendLine("요약은 대화와 동일한 언어로 작성하세요. 글머리 기호(-)로 핵심 사항만 나열하세요.\n---");
foreach (var m in oldMessages)
{
var content = m.Content ?? "";
if (content.StartsWith("{\"_tool_use_blocks\""))
content = "[도구 호출]";
else if (content.StartsWith("{\"type\":\"tool_result\""))
content = "[도구 결과]";
else if (content.Length > 300)
content = content[..300] + "...";
sb.AppendLine($"[{m.Role}]: {content}");
}
try
{
var summaryMessages = new List<ChatMessage>
{
new() { Role = "user", Content = sb.ToString() }
};
var summary = await llm.SendAsync(summaryMessages, ct);
if (string.IsNullOrEmpty(summary)) return false;
// 메시지 재구성: system + 요약 + 최근 메시지
messages.Clear();
if (systemMsg != null) messages.Add(systemMsg);
messages.Add(new ChatMessage
{
Role = "user",
Content = $"[이전 대화 요약 — {oldMessages.Count}개 메시지 압축]\n{summary}",
Timestamp = DateTime.Now,
});
messages.Add(new ChatMessage
{
Role = "assistant",
Content = "이전 대화 내용을 확인했습니다. 이어서 작업하겠습니다.",
Timestamp = DateTime.Now,
});
messages.AddRange(recentMessages);
return true;
}
catch (Exception ex)
{
LogService.Warn($"Context Condenser 요약 실패: {ex.Message}");
return false;
}
}
}

View File

@@ -0,0 +1,93 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// CSV (.csv) 파일을 생성하는 내장 스킬.
/// LLM이 헤더와 데이터 행을 전달하면 CSV 파일을 생성합니다.
/// </summary>
public class CsvSkill : IAgentTool
{
public string Name => "csv_create";
public string Description => "Create a CSV (.csv) file with structured data. Provide headers and rows as JSON arrays.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "Output file path (.csv). Relative to work folder." },
["headers"] = new() { Type = "array", Description = "Column headers as JSON array of strings.", Items = new() { Type = "string" } },
["rows"] = new() { Type = "array", Description = "Data rows as JSON array of arrays.", Items = new() { Type = "array", Items = new() { Type = "string" } } },
["encoding"] = new() { Type = "string", Description = "File encoding: 'utf-8' (default) or 'euc-kr'." },
},
Required = ["path", "headers", "rows"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var encodingName = args.TryGetProperty("encoding", out var enc) ? enc.GetString() ?? "utf-8" : "utf-8";
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
if (!fullPath.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
fullPath += ".csv";
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
try
{
var headers = args.GetProperty("headers");
var rows = args.GetProperty("rows");
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
Encoding fileEncoding;
try { fileEncoding = Encoding.GetEncoding(encodingName); }
catch { fileEncoding = new UTF8Encoding(true); }
var sb = new StringBuilder();
// 헤더
var headerValues = new List<string>();
foreach (var h in headers.EnumerateArray())
headerValues.Add(EscapeCsvField(h.GetString() ?? ""));
sb.AppendLine(string.Join(",", headerValues));
// 데이터
int rowCount = 0;
foreach (var row in rows.EnumerateArray())
{
var fields = new List<string>();
foreach (var cell in row.EnumerateArray())
fields.Add(EscapeCsvField(cell.ToString()));
sb.AppendLine(string.Join(",", fields));
rowCount++;
}
await File.WriteAllTextAsync(fullPath, sb.ToString(), fileEncoding, ct);
return ToolResult.Ok(
$"CSV 파일 생성 완료: {fullPath}\n열: {headerValues.Count}, 행: {rowCount}, 인코딩: {encodingName}",
fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"CSV 생성 실패: {ex.Message}");
}
}
private static string EscapeCsvField(string field)
{
if (field.Contains(',') || field.Contains('"') || field.Contains('\n') || field.Contains('\r'))
return $"\"{field.Replace("\"", "\"\"")}\"";
return field;
}
}

View File

@@ -0,0 +1,359 @@
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>
/// CSV/JSON 데이터를 그룹화, 피벗, 집계하는 도구.
/// LINQ 기반 순수 C# 구현으로 외부 의존성 없음.
/// </summary>
public class DataPivotTool : IAgentTool
{
public string Name => "data_pivot";
public string Description =>
"Group, pivot, and aggregate CSV/JSON data without external dependencies. " +
"Supports: group_by columns, aggregate functions (sum/avg/count/min/max), " +
"filter conditions, sorting, and output as table/csv/json.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["source_path"] = new() { Type = "string", Description = "Path to CSV or JSON data file." },
["group_by"] = new()
{
Type = "array",
Description = "Column names to group by.",
Items = new() { Type = "string" }
},
["aggregates"] = new()
{
Type = "array",
Description = "Aggregation specs: [{\"column\": \"sales\", \"function\": \"sum\"}, ...]. " +
"Functions: sum, avg, count, min, max.",
Items = new() { Type = "object" }
},
["filter"] = new()
{
Type = "string",
Description = "Optional filter expression: 'column == value' or 'column > 100'. " +
"Supports: ==, !=, >, <, >=, <=, contains. " +
"Multiple conditions: 'region == Seoul AND year >= 2025'."
},
["sort_by"] = new() { Type = "string", Description = "Column name to sort results by. Prefix with '-' for descending." },
["top_n"] = new() { Type = "integer", Description = "Limit results to top N rows. Default: all rows." },
["output_format"] = new()
{
Type = "string",
Description = "Output format: table (markdown), csv, json. Default: table",
Enum = ["table", "csv", "json"]
},
},
Required = ["source_path"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var sourcePath = args.GetProperty("source_path").GetString() ?? "";
var fullPath = FileReadTool.ResolvePath(sourcePath, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {fullPath}"));
if (!File.Exists(fullPath))
return Task.FromResult(ToolResult.Fail($"파일 없음: {fullPath}"));
try
{
// 데이터 로드
var ext = Path.GetExtension(fullPath).ToLowerInvariant();
List<Dictionary<string, string>> data;
if (ext == ".json")
data = LoadJson(fullPath);
else
data = LoadCsv(fullPath);
if (data.Count == 0)
return Task.FromResult(ToolResult.Fail("데이터가 비어있습니다."));
var originalCount = data.Count;
// 필터 적용
if (args.TryGetProperty("filter", out var filterEl))
{
var filterStr = filterEl.GetString() ?? "";
if (!string.IsNullOrWhiteSpace(filterStr))
data = ApplyFilter(data, filterStr);
}
// 그룹화 & 집계
List<Dictionary<string, string>> result;
if (args.TryGetProperty("group_by", out var groupEl) && groupEl.ValueKind == JsonValueKind.Array)
{
var groupCols = new List<string>();
foreach (var g in groupEl.EnumerateArray())
groupCols.Add(g.GetString() ?? "");
var aggregates = new List<(string Column, string Function)>();
if (args.TryGetProperty("aggregates", out var aggEl) && aggEl.ValueKind == JsonValueKind.Array)
{
foreach (var agg in aggEl.EnumerateArray())
{
var col = agg.TryGetProperty("column", out var c) ? c.GetString() ?? "" : "";
var func = agg.TryGetProperty("function", out var f) ? f.GetString() ?? "count" : "count";
if (!string.IsNullOrEmpty(col))
aggregates.Add((col, func));
}
}
result = GroupAndAggregate(data, groupCols, aggregates);
}
else
{
result = data;
}
// 정렬
if (args.TryGetProperty("sort_by", out var sortEl))
{
var sortBy = sortEl.GetString() ?? "";
if (!string.IsNullOrWhiteSpace(sortBy))
result = ApplySort(result, sortBy);
}
// Top N
if (args.TryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var topN) && topN > 0)
result = result.Take(topN).ToList();
// 출력 포맷
var outputFormat = args.TryGetProperty("output_format", out var ofmt) ? ofmt.GetString() ?? "table" : "table";
var output = FormatOutput(result, outputFormat);
return Task.FromResult(ToolResult.Ok(
$"📊 데이터 피벗 완료: {originalCount}행 → 필터 후 {data.Count}행 → 결과 {result.Count}행\n\n{output}"));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"데이터 피벗 실패: {ex.Message}"));
}
}
private static List<Dictionary<string, string>> LoadCsv(string path)
{
var read = TextFileCodec.ReadAllText(path);
var lines = TextFileCodec.SplitLines(read.Text);
if (lines.Length < 2) return new();
var headers = ParseCsvLine(lines[0]);
var data = new List<Dictionary<string, string>>();
for (int i = 1; i < lines.Length; i++)
{
if (string.IsNullOrWhiteSpace(lines[i])) continue;
var values = ParseCsvLine(lines[i]);
var row = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (int j = 0; j < headers.Count && j < values.Count; j++)
row[headers[j]] = values[j];
data.Add(row);
}
return data;
}
private static List<string> ParseCsvLine(string line)
{
var result = new List<string>();
var sb = new StringBuilder();
bool inQuote = false;
for (int i = 0; i < line.Length; i++)
{
var c = line[i];
if (c == '"') { inQuote = !inQuote; continue; }
if (c == ',' && !inQuote)
{
result.Add(sb.ToString().Trim());
sb.Clear();
continue;
}
sb.Append(c);
}
result.Add(sb.ToString().Trim());
return result;
}
private static List<Dictionary<string, string>> LoadJson(string path)
{
var json = TextFileCodec.ReadAllText(path).Text;
var doc = JsonDocument.Parse(json);
var data = new List<Dictionary<string, string>>();
var arr = doc.RootElement.ValueKind == JsonValueKind.Array
? doc.RootElement
: doc.RootElement.TryGetProperty("data", out var d) ? d : doc.RootElement;
if (arr.ValueKind != JsonValueKind.Array) return data;
foreach (var item in arr.EnumerateArray())
{
var row = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var prop in item.EnumerateObject())
row[prop.Name] = prop.Value.ToString();
data.Add(row);
}
return data;
}
private static List<Dictionary<string, string>> ApplyFilter(List<Dictionary<string, string>> data, string filter)
{
var conditions = filter.Split(new[] { " AND ", " and " }, StringSplitOptions.TrimEntries);
var result = data;
foreach (var cond in conditions)
{
var match = Regex.Match(cond, @"(\w+)\s*(==|!=|>=|<=|>|<|contains)\s*(.+)");
if (!match.Success) continue;
var col = match.Groups[1].Value;
var op = match.Groups[2].Value;
var val = match.Groups[3].Value.Trim().Trim('\'', '"');
result = result.Where(row =>
{
if (!row.TryGetValue(col, out var cellVal)) return false;
return op switch
{
"==" => cellVal.Equals(val, StringComparison.OrdinalIgnoreCase),
"!=" => !cellVal.Equals(val, StringComparison.OrdinalIgnoreCase),
"contains" => cellVal.Contains(val, StringComparison.OrdinalIgnoreCase),
">" => double.TryParse(cellVal, out var a) && double.TryParse(val, out var b) && a > b,
"<" => double.TryParse(cellVal, out var a2) && double.TryParse(val, out var b2) && a2 < b2,
">=" => double.TryParse(cellVal, out var a3) && double.TryParse(val, out var b3) && a3 >= b3,
"<=" => double.TryParse(cellVal, out var a4) && double.TryParse(val, out var b4) && a4 <= b4,
_ => true
};
}).ToList();
}
return result;
}
private static List<Dictionary<string, string>> GroupAndAggregate(
List<Dictionary<string, string>> data,
List<string> groupCols,
List<(string Column, string Function)> aggregates)
{
var groups = data.GroupBy(row =>
{
var key = new StringBuilder();
foreach (var col in groupCols)
{
row.TryGetValue(col, out var val);
key.Append(val ?? "").Append('|');
}
return key.ToString();
});
var result = new List<Dictionary<string, string>>();
foreach (var group in groups)
{
var row = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// 그룹 키 컬럼
var first = group.First();
foreach (var col in groupCols)
row[col] = first.TryGetValue(col, out var v) ? v : "";
// 집계 컬럼
foreach (var (aggCol, func) in aggregates)
{
var values = group
.Select(r => r.TryGetValue(aggCol, out var v) ? v : "")
.Where(v => double.TryParse(v, out _))
.Select(v => double.Parse(v))
.ToList();
var aggResult = func.ToLowerInvariant() switch
{
"sum" => values.Sum(),
"avg" or "average" => values.Count > 0 ? values.Average() : 0,
"min" => values.Count > 0 ? values.Min() : 0,
"max" => values.Count > 0 ? values.Max() : 0,
"count" => group.Count(),
_ => (double)group.Count()
};
var label = $"{aggCol}_{func}";
row[label] = func == "count" ? ((int)aggResult).ToString() : aggResult.ToString("F2");
}
// count 집계가 없으면 기본 count 추가
if (aggregates.Count == 0)
row["count"] = group.Count().ToString();
result.Add(row);
}
return result;
}
private static List<Dictionary<string, string>> ApplySort(List<Dictionary<string, string>> data, string sortBy)
{
bool desc = sortBy.StartsWith('-');
var col = sortBy.TrimStart('-');
return (desc
? data.OrderByDescending(r => GetSortKey(r, col))
: data.OrderBy(r => GetSortKey(r, col))
).ToList();
}
private static object GetSortKey(Dictionary<string, string> row, string col)
{
if (!row.TryGetValue(col, out var val)) return "";
if (double.TryParse(val, out var num)) return num;
return val;
}
private static string FormatOutput(List<Dictionary<string, string>> data, string format)
{
if (data.Count == 0) return "(결과 없음)";
var columns = data.SelectMany(r => r.Keys).Distinct().ToList();
switch (format)
{
case "json":
return JsonSerializer.Serialize(data, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
});
case "csv":
var csvSb = new StringBuilder();
csvSb.AppendLine(string.Join(",", columns));
foreach (var row in data)
{
var vals = columns.Select(c => row.TryGetValue(c, out var v) ? $"\"{v}\"" : "\"\"");
csvSb.AppendLine(string.Join(",", vals));
}
return csvSb.ToString();
default: // table (markdown)
var sb = new StringBuilder();
// 헤더
sb.AppendLine("| " + string.Join(" | ", columns) + " |");
sb.AppendLine("| " + string.Join(" | ", columns.Select(_ => "---")) + " |");
// 행
foreach (var row in data)
{
var vals = columns.Select(c => row.TryGetValue(c, out var v) ? v : "");
sb.AppendLine("| " + string.Join(" | ", vals) + " |");
}
return sb.ToString();
}
}
}

View File

@@ -0,0 +1,197 @@
using System.Globalization;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>날짜·시간 변환, 타임존, 기간 계산 유틸리티 도구.</summary>
public class DateTimeTool : IAgentTool
{
public string Name => "datetime_tool";
public string Description =>
"Date/time utility tool. Actions: " +
"'now' — get current date/time in various formats; " +
"'parse' — parse a date string into standard format; " +
"'diff' — calculate difference between two dates; " +
"'add' — add/subtract days/hours/minutes to a date; " +
"'epoch' — convert between Unix epoch and datetime; " +
"'format' — format a date into specified pattern.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "Action to perform",
Enum = ["now", "parse", "diff", "add", "epoch", "format"],
},
["date"] = new()
{
Type = "string",
Description = "Date string (for parse/diff/add/format/epoch). For epoch: Unix timestamp in seconds.",
},
["date2"] = new()
{
Type = "string",
Description = "Second date string (for diff action)",
},
["amount"] = new()
{
Type = "string",
Description = "Amount to add (for add action). E.g. '7' for 7 days",
},
["unit"] = new()
{
Type = "string",
Description = "Unit for add action",
Enum = ["days", "hours", "minutes", "seconds", "months", "years"],
},
["pattern"] = new()
{
Type = "string",
Description = "Format pattern (for format action). E.g. 'yyyy-MM-dd HH:mm:ss', 'ddd MMM d yyyy'",
},
},
Required = ["action"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
try
{
return Task.FromResult(action switch
{
"now" => Now(),
"parse" => Parse(args),
"diff" => Diff(args),
"add" => Add(args),
"epoch" => Epoch(args),
"format" => FormatDate(args),
_ => ToolResult.Fail($"Unknown action: {action}"),
});
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"DateTime 오류: {ex.Message}"));
}
}
private static ToolResult Now()
{
var now = DateTime.Now;
var utc = DateTime.UtcNow;
var epoch = new DateTimeOffset(utc).ToUnixTimeSeconds();
return ToolResult.Ok(
$"Local: {now:yyyy-MM-dd HH:mm:ss (ddd)} ({TimeZoneInfo.Local.DisplayName})\n" +
$"UTC: {utc:yyyy-MM-dd HH:mm:ss}\n" +
$"ISO: {now:O}\n" +
$"Epoch: {epoch}\n" +
$"Week: {CultureInfo.InvariantCulture.Calendar.GetWeekOfYear(now, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday)}");
}
private static ToolResult Parse(JsonElement args)
{
var dateStr = args.TryGetProperty("date", out var d) ? d.GetString() ?? "" : "";
if (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
if (!DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt) &&
!DateTime.TryParse(dateStr, CultureInfo.CurrentCulture, DateTimeStyles.None, out dt))
return ToolResult.Fail($"Cannot parse date: '{dateStr}'");
return ToolResult.Ok(
$"Parsed: {dt:yyyy-MM-dd HH:mm:ss}\n" +
$"Day: {dt:dddd}\n" +
$"ISO: {dt:O}\n" +
$"Epoch: {new DateTimeOffset(dt).ToUnixTimeSeconds()}");
}
private static ToolResult Diff(JsonElement args)
{
var d1 = args.TryGetProperty("date", out var v1) ? v1.GetString() ?? "" : "";
var d2 = args.TryGetProperty("date2", out var v2) ? v2.GetString() ?? "" : "";
if (string.IsNullOrEmpty(d1) || string.IsNullOrEmpty(d2))
return ToolResult.Fail("'date' and 'date2' parameters are required");
if (!DateTime.TryParse(d1, out var dt1)) return ToolResult.Fail($"Cannot parse date: '{d1}'");
if (!DateTime.TryParse(d2, out var dt2)) return ToolResult.Fail($"Cannot parse date: '{d2}'");
var diff = dt2 - dt1;
return ToolResult.Ok(
$"From: {dt1:yyyy-MM-dd HH:mm:ss}\n" +
$"To: {dt2:yyyy-MM-dd HH:mm:ss}\n\n" +
$"Difference:\n" +
$" {Math.Abs(diff.TotalDays):F1} days\n" +
$" {Math.Abs(diff.TotalHours):F1} hours\n" +
$" {Math.Abs(diff.TotalMinutes):F0} minutes\n" +
$" {Math.Abs(diff.TotalSeconds):F0} seconds\n" +
$" ({(diff.TotalDays >= 0 ? "forward" : "backward")})");
}
private static ToolResult Add(JsonElement args)
{
var dateStr = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
var amountStr = args.TryGetProperty("amount", out var av) ? av.GetString() ?? "0" : "0";
var unit = args.TryGetProperty("unit", out var uv) ? uv.GetString() ?? "days" : "days";
if (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
if (!DateTime.TryParse(dateStr, out var dt)) return ToolResult.Fail($"Cannot parse date: '{dateStr}'");
if (!double.TryParse(amountStr, out var amount)) return ToolResult.Fail($"Invalid amount: '{amountStr}'");
var result = unit switch
{
"days" => dt.AddDays(amount),
"hours" => dt.AddHours(amount),
"minutes" => dt.AddMinutes(amount),
"seconds" => dt.AddSeconds(amount),
"months" => dt.AddMonths((int)amount),
"years" => dt.AddYears((int)amount),
_ => dt.AddDays(amount),
};
return ToolResult.Ok(
$"Original: {dt:yyyy-MM-dd HH:mm:ss}\n" +
$"Added: {amount} {unit}\n" +
$"Result: {result:yyyy-MM-dd HH:mm:ss} ({result:dddd})");
}
private static ToolResult Epoch(JsonElement args)
{
var input = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
if (string.IsNullOrEmpty(input)) return ToolResult.Fail("'date' parameter is required");
// 숫자면 epoch → datetime
if (long.TryParse(input, out var epoch))
{
var dt = DateTimeOffset.FromUnixTimeSeconds(epoch);
return ToolResult.Ok(
$"Epoch: {epoch}\n" +
$"UTC: {dt.UtcDateTime:yyyy-MM-dd HH:mm:ss}\n" +
$"Local: {dt.LocalDateTime:yyyy-MM-dd HH:mm:ss}");
}
// 문자열이면 datetime → epoch
if (DateTime.TryParse(input, out var parsed))
{
var e = new DateTimeOffset(parsed).ToUnixTimeSeconds();
return ToolResult.Ok(
$"Date: {parsed:yyyy-MM-dd HH:mm:ss}\n" +
$"Epoch: {e}");
}
return ToolResult.Fail($"Cannot parse: '{input}'");
}
private static ToolResult FormatDate(JsonElement args)
{
var dateStr = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
var pattern = args.TryGetProperty("pattern", out var pv) ? pv.GetString() ?? "yyyy-MM-dd" : "yyyy-MM-dd";
if (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
if (!DateTime.TryParse(dateStr, out var dt)) return ToolResult.Fail($"Cannot parse date: '{dateStr}'");
return ToolResult.Ok(dt.ToString(pattern, CultureInfo.InvariantCulture));
}
}

View File

@@ -0,0 +1,217 @@
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.Json;
using Microsoft.Win32;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 개발 환경 감지 도구.
/// IDE, 언어 런타임, 빌드 도구의 설치 여부와 경로를 자동 감지합니다.
/// 사내 환경에서 설치된 도구만 사용하도록 LLM에 정보를 제공합니다.
/// </summary>
public class DevEnvDetectTool : IAgentTool
{
public string Name => "dev_env_detect";
public string Description =>
"Detect installed development tools on this machine. " +
"Returns: IDEs (VS Code, Visual Studio, IntelliJ, PyCharm), " +
"language runtimes (dotnet, python/conda, java, node, gcc/g++), " +
"and build tools (MSBuild, Maven, Gradle, CMake, npm/yarn). " +
"Use this before running build/test commands to know what's available.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["category"] = new()
{
Type = "string",
Description = "Detection category: all (default), ides, runtimes, build_tools",
Enum = ["all", "ides", "runtimes", "build_tools"],
},
},
Required = []
};
// 60초 캐시
private static (DateTime Time, string Result)? _cache;
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var category = args.TryGetProperty("category", out var cat) ? cat.GetString() ?? "all" : "all";
// 캐시 확인
if (_cache.HasValue && (DateTime.UtcNow - _cache.Value.Time).TotalSeconds < 60)
return Task.FromResult(ToolResult.Ok(_cache.Value.Result));
var sb = new StringBuilder();
sb.AppendLine("=== 개발 환경 감지 보고서 ===\n");
if (category is "all" or "ides")
{
sb.AppendLine("## IDE");
DetectIde(sb, "VS Code", DetectVsCode);
DetectIde(sb, "Visual Studio", DetectVisualStudio);
DetectIde(sb, "IntelliJ IDEA", () => DetectJetBrains("IntelliJ"));
DetectIde(sb, "PyCharm", () => DetectJetBrains("PyCharm"));
sb.AppendLine();
}
if (category is "all" or "runtimes")
{
sb.AppendLine("## Language Runtimes");
DetectCommand(sb, "dotnet", "dotnet --version", "DOTNET_ROOT");
DetectCommand(sb, "python", "python --version", "PYTHON_HOME");
DetectCommand(sb, "conda", "conda --version", "CONDA_PREFIX");
DetectCommand(sb, "java", "java -version", "JAVA_HOME");
DetectCommand(sb, "node", "node --version", "NODE_HOME");
DetectCommand(sb, "npm", "npm --version", null);
DetectCommand(sb, "gcc", "gcc --version", null);
DetectCommand(sb, "g++", "g++ --version", null);
sb.AppendLine();
}
if (category is "all" or "build_tools")
{
sb.AppendLine("## Build Tools");
DetectCommand(sb, "MSBuild", "msbuild -version", null);
DetectCommand(sb, "Maven", "mvn --version", "MAVEN_HOME");
DetectCommand(sb, "Gradle", "gradle --version", "GRADLE_HOME");
DetectCommand(sb, "CMake", "cmake --version", null);
DetectCommand(sb, "yarn", "yarn --version", null);
DetectCommand(sb, "pip", "pip --version", null);
sb.AppendLine();
}
var result = sb.ToString();
_cache = (DateTime.UtcNow, result);
return Task.FromResult(ToolResult.Ok(result));
}
private static void DetectIde(StringBuilder sb, string name, Func<string?> detector)
{
var path = detector();
sb.AppendLine(path != null ? $" ✅ {name}: {path}" : $" ❌ {name}: 미설치");
}
private static void DetectCommand(StringBuilder sb, string name, string versionCmd, string? envVar)
{
// 환경변수 확인
string? envPath = null;
if (envVar != null)
envPath = Environment.GetEnvironmentVariable(envVar);
// where.exe로 PATH 확인
var wherePath = RunQuick("where.exe", name);
var version = "";
if (wherePath != null)
{
// 버전 확인
var parts = versionCmd.Split(' ', 2);
var verOutput = RunQuick(parts[0], parts.Length > 1 ? parts[1] : "");
if (verOutput != null)
version = verOutput.Split('\n')[0].Trim();
}
if (wherePath != null)
sb.AppendLine($" ✅ {name}: {version} ({wherePath.Split('\n')[0].Trim()})");
else if (envPath != null)
sb.AppendLine($" ⚠ {name}: 환경변수만 ({envVar}={envPath})");
else
sb.AppendLine($" ❌ {name}: 미설치");
}
private static string? RunQuick(string exe, string args)
{
try
{
var psi = new ProcessStartInfo(exe, args)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var proc = Process.Start(psi);
if (proc == null) return null;
var output = proc.StandardOutput.ReadToEnd();
var error = proc.StandardError.ReadToEnd();
proc.WaitForExit(5000);
var result = string.IsNullOrWhiteSpace(output) ? error : output;
return string.IsNullOrWhiteSpace(result) ? null : result.Trim();
}
catch { return null; }
}
private static string? DetectVsCode()
{
// PATH 확인
var where = RunQuick("where.exe", "code");
if (where != null) return where.Split('\n')[0].Trim();
// 기본 설치 경로
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var defaultPath = Path.Combine(localAppData, "Programs", "Microsoft VS Code", "Code.exe");
return File.Exists(defaultPath) ? defaultPath : null;
}
private static string? DetectVisualStudio()
{
try
{
// 레지스트리에서 Visual Studio 설치 경로 검색
using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\VisualStudio\SxS\VS7");
if (key != null)
{
foreach (var name in key.GetValueNames().OrderByDescending(n => n))
{
var path = key.GetValue(name)?.ToString();
if (!string.IsNullOrEmpty(path) && Directory.Exists(path))
return $"Visual Studio {name} ({path})";
}
}
// WOW6432Node 확인
using var key32 = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\WOW6432Node\Microsoft\VisualStudio\SxS\VS7");
if (key32 != null)
{
foreach (var name in key32.GetValueNames().OrderByDescending(n => n))
{
var path = key32.GetValue(name)?.ToString();
if (!string.IsNullOrEmpty(path) && Directory.Exists(path))
return $"Visual Studio {name} ({path})";
}
}
}
catch { }
// devenv.exe PATH 확인
var where = RunQuick("where.exe", "devenv");
return where != null ? where.Split('\n')[0].Trim() : null;
}
private static string? DetectJetBrains(string product)
{
try
{
using var key = Registry.LocalMachine.OpenSubKey($@"SOFTWARE\JetBrains\{product}");
if (key != null)
{
var subKeys = key.GetSubKeyNames().OrderByDescending(n => n).ToArray();
if (subKeys.Length > 0)
{
using var verKey = key.OpenSubKey(subKeys[0]);
var installDir = verKey?.GetValue("InstallDir")?.ToString()
?? verKey?.GetValue("")?.ToString();
if (!string.IsNullOrEmpty(installDir))
return $"{product} {subKeys[0]} ({installDir})";
}
}
}
catch { }
return null;
}
}

View File

@@ -0,0 +1,178 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 파일 변경 사항을 미리 보여주고 사용자 승인을 받는 도구.
/// 통합 diff를 생성하여 "[PREVIEW_PENDING]" 접두사와 함께 반환합니다.
/// </summary>
public class DiffPreviewTool : IAgentTool
{
public string Name => "diff_preview";
public string Description =>
"Preview file changes before applying them. Shows a unified diff and waits for user approval. " +
"If approved, writes the new content to the file. If rejected, no changes are made. " +
"The diff output is prefixed with [PREVIEW_PENDING] so the UI can show an approval panel.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new()
{
Type = "string",
Description = "File path to modify",
},
["new_content"] = new()
{
Type = "string",
Description = "Proposed new content for the file",
},
["description"] = new()
{
Type = "string",
Description = "Description of the changes (optional)",
},
},
Required = ["path", "new_content"],
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var rawPath = args.GetProperty("path").GetString() ?? "";
var newContent = args.GetProperty("new_content").GetString() ?? "";
var description = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
if (!context.IsPathAllowed(path))
return ToolResult.Fail($"경로 접근 차단: {path}");
try
{
// 원본 파일 읽기 (없으면 새 파일 생성으로 처리)
var originalContent = "";
var isNewFile = !File.Exists(path);
var sourceEncoding = TextFileCodec.Utf8NoBom;
var sourceHasBom = false;
if (!isNewFile)
{
var read = await TextFileCodec.ReadAllTextAsync(path, ct);
originalContent = read.Text;
sourceEncoding = read.Encoding;
sourceHasBom = read.HasBom;
}
// 통합 diff 생성
var originalLines = originalContent.Split('\n').Select(l => l.TrimEnd('\r')).ToArray();
var newLines = newContent.Split('\n').Select(l => l.TrimEnd('\r')).ToArray();
var diff = GenerateUnifiedDiff(originalLines, newLines, path);
var sb = new StringBuilder();
sb.AppendLine("[PREVIEW_PENDING]");
if (!string.IsNullOrEmpty(description))
sb.AppendLine($"변경 설명: {description}");
sb.AppendLine($"파일: {path}");
sb.AppendLine(isNewFile ? "상태: 새 파일 생성" : "상태: 기존 파일 수정");
sb.AppendLine();
if (string.IsNullOrEmpty(diff))
sb.AppendLine("변경 사항 없음 — 내용이 동일합니다.");
else
sb.Append(diff);
// 쓰기 권한 확인 (AskPermission 콜백 사용 — CustomMessageBox)
if (!await context.CheckWritePermissionAsync("diff_preview", path))
return ToolResult.Ok($"사용자가 파일 변경을 거부했습니다.\n\n{sb}");
// 디렉토리 생성
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
var writeEncoding = isNewFile
? TextFileCodec.Utf8NoBom
: TextFileCodec.ResolveWriteEncoding(sourceEncoding, sourceHasBom);
await TextFileCodec.WriteAllTextAsync(path, newContent, writeEncoding, ct);
return ToolResult.Ok($"변경 사항이 적용되었습니다: {path}\n\n{sb}", path);
}
catch (Exception ex)
{
return ToolResult.Fail($"미리보기 오류: {ex.Message}");
}
}
private static string GenerateUnifiedDiff(string[] original, string[] modified, string filePath)
{
var sb = new StringBuilder();
sb.AppendLine($"--- {filePath} (원본)");
sb.AppendLine($"+++ {filePath} (수정)");
// 간단한 LCS 기반 diff
var lcs = ComputeLcs(original, modified);
int oi = 0, mi = 0, ci = 0;
var hunks = new List<(int os, int oe, int ms, int me)>();
while (oi < original.Length || mi < modified.Length)
{
if (ci < lcs.Count && oi < original.Length && mi < modified.Length
&& original[oi] == lcs[ci] && modified[mi] == lcs[ci])
{
oi++; mi++; ci++;
}
else
{
var hos = oi;
var hms = mi;
while (oi < original.Length && (ci >= lcs.Count || original[oi] != lcs[ci]))
oi++;
while (mi < modified.Length && (ci >= lcs.Count || modified[mi] != lcs[ci]))
mi++;
hunks.Add((hos, oi, hms, mi));
}
}
if (hunks.Count == 0) return "";
foreach (var (os, oe, ms, me) in hunks)
{
sb.AppendLine($"@@ -{os + 1},{oe - os} +{ms + 1},{me - ms} @@");
for (var i = os; i < oe; i++)
sb.AppendLine($"-{original[i]}");
for (var i = ms; i < me; i++)
sb.AppendLine($"+{modified[i]}");
}
return sb.ToString();
}
private static List<string> ComputeLcs(string[] a, string[] b)
{
var m = a.Length;
var n = b.Length;
// 메모리 절약: 큰 파일은 전체를 diff로 표시
if ((long)m * n > 10_000_000)
return [];
var dp = new int[m + 1, n + 1];
for (var i = m - 1; i >= 0; i--)
for (var j = n - 1; j >= 0; j--)
dp[i, j] = a[i] == b[j] ? dp[i + 1, j + 1] + 1 : Math.Max(dp[i + 1, j], dp[i, j + 1]);
var result = new List<string>();
int x = 0, y = 0;
while (x < m && y < n)
{
if (a[x] == b[y]) { result.Add(a[x]); x++; y++; }
else if (dp[x + 1, y] >= dp[x, y + 1]) x++;
else y++;
}
return result;
}
}

View File

@@ -0,0 +1,158 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 두 텍스트 또는 파일을 비교하여 통합 diff 결과를 출력하는 도구.
/// </summary>
public class DiffTool : IAgentTool
{
public string Name => "diff_tool";
public string Description =>
"Compare two texts or files and output a unified diff. " +
"Use 'text' mode to compare two text strings directly, " +
"or 'file' mode to compare two files by path.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["mode"] = new()
{
Type = "string",
Description = "Comparison mode",
Enum = ["text", "file"],
},
["left"] = new()
{
Type = "string",
Description = "Left text content or file path",
},
["right"] = new()
{
Type = "string",
Description = "Right text content or file path",
},
["left_label"] = new()
{
Type = "string",
Description = "Label for left side (optional, default: 'left')",
},
["right_label"] = new()
{
Type = "string",
Description = "Label for right side (optional, default: 'right')",
},
},
Required = ["mode", "left", "right"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var mode = args.GetProperty("mode").GetString() ?? "text";
var left = args.GetProperty("left").GetString() ?? "";
var right = args.GetProperty("right").GetString() ?? "";
var leftLabel = args.TryGetProperty("left_label", out var ll) ? ll.GetString() ?? "left" : "left";
var rightLabel = args.TryGetProperty("right_label", out var rl) ? rl.GetString() ?? "right" : "right";
try
{
if (mode == "file")
{
var leftPath = Path.IsPathRooted(left) ? left : Path.Combine(context.WorkFolder, left);
var rightPath = Path.IsPathRooted(right) ? right : Path.Combine(context.WorkFolder, right);
if (!File.Exists(leftPath)) return Task.FromResult(ToolResult.Fail($"Left file not found: {leftPath}"));
if (!File.Exists(rightPath)) return Task.FromResult(ToolResult.Fail($"Right file not found: {rightPath}"));
left = TextFileCodec.ReadAllText(leftPath).Text;
right = TextFileCodec.ReadAllText(rightPath).Text;
leftLabel = Path.GetFileName(leftPath);
rightLabel = Path.GetFileName(rightPath);
}
var leftLines = left.Split('\n').Select(l => l.TrimEnd('\r')).ToArray();
var rightLines = right.Split('\n').Select(l => l.TrimEnd('\r')).ToArray();
var diff = GenerateUnifiedDiff(leftLines, rightLines, leftLabel, rightLabel);
if (string.IsNullOrEmpty(diff))
return Task.FromResult(ToolResult.Ok("No differences found — files/texts are identical."));
if (diff.Length > 10000) diff = diff[..10000] + "\n... (truncated)";
return Task.FromResult(ToolResult.Ok(diff));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"Diff 오류: {ex.Message}"));
}
}
private static string GenerateUnifiedDiff(string[] left, string[] right, string leftLabel, string rightLabel)
{
// 간단한 LCS 기반 diff
var lcs = ComputeLcs(left, right);
var sb = new System.Text.StringBuilder();
sb.AppendLine($"--- {leftLabel}");
sb.AppendLine($"+++ {rightLabel}");
int li = 0, ri = 0, ci = 0;
var hunks = new List<(int ls, int le, int rs, int re)>();
// hunk 수집
while (li < left.Length || ri < right.Length)
{
if (ci < lcs.Count && li < left.Length && ri < right.Length && left[li] == lcs[ci] && right[ri] == lcs[ci])
{
li++; ri++; ci++;
}
else
{
var hls = li; var hrs = ri;
while (li < left.Length && (ci >= lcs.Count || left[li] != lcs[ci]))
li++;
while (ri < right.Length && (ci >= lcs.Count || right[ri] != lcs[ci]))
ri++;
hunks.Add((hls, li, hrs, ri));
}
}
if (hunks.Count == 0) return "";
// 출력
foreach (var (ls, le, rs, re) in hunks)
{
var contextStart = Math.Max(0, ls - 3);
sb.AppendLine($"@@ -{ls + 1},{le - ls} +{rs + 1},{re - rs} @@");
for (var i = ls; i < le; i++)
sb.AppendLine($"-{left[i]}");
for (var i = rs; i < re; i++)
sb.AppendLine($"+{right[i]}");
}
return sb.ToString();
}
private static List<string> ComputeLcs(string[] a, string[] b)
{
var m = a.Length; var n = b.Length;
// 메모리 절약을 위해 큰 파일은 제한
if ((long)m * n > 10_000_000)
return new List<string>(); // 너무 큰 경우 전체를 diff로 표시
var dp = new int[m + 1, n + 1];
for (var i = m - 1; i >= 0; i--)
for (var j = n - 1; j >= 0; j--)
dp[i, j] = a[i] == b[j] ? dp[i + 1, j + 1] + 1 : Math.Max(dp[i + 1, j], dp[i, j + 1]);
var result = new List<string>();
int x = 0, y = 0;
while (x < m && y < n)
{
if (a[x] == b[y]) { result.Add(a[x]); x++; y++; }
else if (dp[x + 1, y] >= dp[x, y + 1]) x++;
else y++;
}
return result;
}
}

View File

@@ -0,0 +1,370 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 여러 섹션의 내용을 하나의 완성된 문서로 조립하는 도구.
/// 멀티패스 문서 생성의 3단계: 개별 생성된 섹션들을 최종 문서로 결합합니다.
/// </summary>
public class DocumentAssemblerTool : IAgentTool
{
public string Name => "document_assemble";
public string Description =>
"Assemble multiple individually-written sections into a single complete document. " +
"Use this after writing each section separately with document_plan. " +
"Supports HTML, DOCX, and Markdown output. " +
"Automatically adds table of contents, cover page, and section numbering for HTML. " +
"After assembly, the document is auto-validated for quality issues.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "Output file path. Relative to work folder." },
["title"] = new() { Type = "string", Description = "Document title" },
["sections"] = new()
{
Type = "array",
Description = "Array of section objects: [{\"heading\": \"1. 개요\", \"content\": \"HTML or markdown body...\", \"level\": 1}]. " +
"content should be the detailed text for each section.",
Items = new() { Type = "object" }
},
["format"] = new()
{
Type = "string",
Description = "Output format: html, docx, markdown. Default: html",
Enum = ["html", "docx", "markdown"]
},
["mood"] = new()
{
Type = "string",
Description = "Design theme for HTML output: modern, professional, creative, corporate, dashboard, etc. Default: professional"
},
["toc"] = new() { Type = "boolean", Description = "Auto-generate table of contents. Default: true" },
["cover_subtitle"] = new() { Type = "string", Description = "Subtitle for cover page. If provided, a cover page is added." },
["header"] = new() { Type = "string", Description = "Header text for DOCX output." },
["footer"] = new() { Type = "string", Description = "Footer text for DOCX output. Use {page} for page number." },
},
Required = ["path", "title", "sections"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var title = args.GetProperty("title").GetString() ?? "Document";
var format = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "html" : "html";
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "professional" : "professional";
var useToc = !args.TryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true
var coverSubtitle = args.TryGetProperty("cover_subtitle", out var cs) ? cs.GetString() : null;
var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null;
var footerText = args.TryGetProperty("footer", out var ftr) ? ftr.GetString() : null;
if (!args.TryGetProperty("sections", out var sectionsEl) || sectionsEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("sections 배열이 필요합니다.");
var sections = new List<(string Heading, string Content, int Level)>();
foreach (var sec in sectionsEl.EnumerateArray())
{
var heading = sec.TryGetProperty("heading", out var h) ? h.GetString() ?? "" : "";
var content = sec.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
var level = sec.TryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
if (!string.IsNullOrWhiteSpace(heading) || !string.IsNullOrWhiteSpace(content))
sections.Add((heading, content, level));
}
if (sections.Count == 0)
return ToolResult.Fail("조립할 섹션이 없습니다.");
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
// 확장자 자동 추가
var ext = format switch
{
"docx" => ".docx",
"markdown" => ".md",
_ => ".html"
};
if (!fullPath.EndsWith(ext, StringComparison.OrdinalIgnoreCase))
fullPath += ext;
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
try
{
string resultMsg;
switch (format)
{
case "docx":
resultMsg = AssembleDocx(fullPath, title, sections, headerText, footerText);
break;
case "markdown":
resultMsg = AssembleMarkdown(fullPath, title, sections);
break;
default:
resultMsg = AssembleHtml(fullPath, title, sections, mood, useToc, coverSubtitle);
break;
}
// 품질 요약 통계
var totalChars = sections.Sum(s => s.Content.Length);
var totalWords = sections.Sum(s => EstimateWordCount(s.Content));
var pageEstimate = Math.Max(1, totalWords / 500);
return ToolResult.Ok(
$"✅ 문서 조립 완료: {Path.GetFileName(fullPath)}\n" +
$" 섹션: {sections.Count}개 | 글자: {totalChars:N0} | 단어: ~{totalWords:N0} | 예상 페이지: ~{pageEstimate}\n" +
$"{resultMsg}", fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"문서 조립 실패: {ex.Message}");
}
}
private string AssembleHtml(string path, string title, List<(string Heading, string Content, int Level)> sections,
string mood, bool toc, string? coverSubtitle)
{
var sb = new StringBuilder();
var style = TemplateService.GetCss(mood);
var moodInfo = TemplateService.GetMood(mood);
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"ko\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"utf-8\">");
sb.AppendLine($"<title>{Escape(title)}</title>");
sb.AppendLine("<style>");
sb.AppendLine(style);
// 추가 조립용 스타일
sb.AppendLine(@"
.assembled-doc { max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }
.assembled-doc h1 { font-size: 28px; margin-bottom: 8px; }
.assembled-doc h2 { font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }
.assembled-doc h3 { font-size: 18px; margin-top: 24px; margin-bottom: 8px; }
.assembled-doc .section-content { line-height: 1.8; margin-bottom: 20px; }
.assembled-doc .toc { background: #f8f9fa; border-radius: 12px; padding: 20px 28px; margin: 24px 0 32px; }
.assembled-doc .toc h3 { margin-top: 0; }
.assembled-doc .toc a { color: inherit; text-decoration: none; }
.assembled-doc .toc a:hover { text-decoration: underline; }
.assembled-doc .toc ul { list-style: none; padding-left: 0; }
.assembled-doc .toc li { padding: 4px 0; }
.cover-page { text-align: center; padding: 120px 40px 80px; page-break-after: always; }
.cover-page h1 { font-size: 36px; margin-bottom: 16px; }
.cover-page .subtitle { font-size: 18px; color: #666; margin-bottom: 40px; }
.cover-page .date { font-size: 14px; color: #999; }
@media print { .cover-page { page-break-after: always; } .assembled-doc h2 { page-break-before: auto; } }
");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
// 커버 페이지
if (!string.IsNullOrWhiteSpace(coverSubtitle))
{
sb.AppendLine("<div class=\"cover-page\">");
sb.AppendLine($"<h1>{Escape(title)}</h1>");
sb.AppendLine($"<div class=\"subtitle\">{Escape(coverSubtitle)}</div>");
sb.AppendLine($"<div class=\"date\">{DateTime.Now:yyyy년 MM월 dd일}</div>");
sb.AppendLine("</div>");
}
sb.AppendLine("<div class=\"assembled-doc\">");
if (string.IsNullOrWhiteSpace(coverSubtitle))
sb.AppendLine($"<h1>{Escape(title)}</h1>");
// TOC 생성
if (toc && sections.Count > 1)
{
sb.AppendLine("<div class=\"toc\">");
sb.AppendLine("<h3>📋 목차</h3>");
sb.AppendLine("<ul>");
for (int i = 0; i < sections.Count; i++)
{
var indent = sections[i].Level > 1 ? " style=\"padding-left:20px\"" : "";
sb.AppendLine($"<li{indent}><a href=\"#section-{i + 1}\">{Escape(sections[i].Heading)}</a></li>");
}
sb.AppendLine("</ul>");
sb.AppendLine("</div>");
}
// 섹션 본문
for (int i = 0; i < sections.Count; i++)
{
var (heading, content, level) = sections[i];
var tag = level <= 1 ? "h2" : "h3";
sb.AppendLine($"<{tag} id=\"section-{i + 1}\">{Escape(heading)}</{tag}>");
sb.AppendLine($"<div class=\"section-content\">{content}</div>");
}
sb.AppendLine("</div>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
var issues = ValidateBasic(sb.ToString());
return issues.Count > 0
? $" ⚠ 품질 검증 이슈 {issues.Count}건: {string.Join("; ", issues)}"
: " ✓ 품질 검증 통과";
}
private string AssembleDocx(string path, string title, List<(string Heading, string Content, int Level)> sections,
string? headerText, string? footerText)
{
// DOCX 조립: DocxSkill의 sections 형식으로 변환하여 OpenXML 사용
using var doc = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create(
path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body());
// 제목
var titlePara = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
var titleRun = new DocumentFormat.OpenXml.Wordprocessing.Run();
titleRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties
{
Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(),
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "48" }
});
titleRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(title));
titlePara.AppendChild(titleRun);
body.AppendChild(titlePara);
// 빈 줄
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph());
// 각 섹션
foreach (var (heading, content, level) in sections)
{
// 섹션 제목
var headPara = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
var headRun = new DocumentFormat.OpenXml.Wordprocessing.Run();
headRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.RunProperties
{
Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold(),
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = level <= 1 ? "32" : "28" },
Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "2B579A" },
});
headRun.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(heading));
headPara.AppendChild(headRun);
body.AppendChild(headPara);
// 섹션 본문 (줄 단위 분할)
var lines = StripHtmlTags(content).Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
var run = new DocumentFormat.OpenXml.Wordprocessing.Run();
run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(line.Trim())
{
Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve
});
para.AppendChild(run);
body.AppendChild(para);
}
// 섹션 간 빈 줄
body.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph());
}
return " ✓ DOCX 조립 완료";
}
private string AssembleMarkdown(string path, string title, List<(string Heading, string Content, int Level)> sections)
{
var sb = new StringBuilder();
sb.AppendLine($"# {title}");
sb.AppendLine();
sb.AppendLine($"*작성일: {DateTime.Now:yyyy-MM-dd}*");
sb.AppendLine();
// TOC
if (sections.Count > 1)
{
sb.AppendLine("## 목차");
sb.AppendLine();
foreach (var (heading, _, _) in sections)
{
var anchor = heading.Replace(" ", "-").ToLowerInvariant();
sb.AppendLine($"- [{heading}](#{anchor})");
}
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine();
}
foreach (var (heading, content, level) in sections)
{
var prefix = level <= 1 ? "##" : "###";
sb.AppendLine($"{prefix} {heading}");
sb.AppendLine();
sb.AppendLine(StripHtmlTags(content));
sb.AppendLine();
}
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
return " ✓ Markdown 조립 완료";
}
private static int EstimateWordCount(string text)
{
if (string.IsNullOrWhiteSpace(text)) return 0;
var plain = StripHtmlTags(text);
// 한국어: 글자 수 / 3 ≈ 단어 수, 영어: 공백 분리
var spaces = plain.Count(c => c == ' ');
var koreanChars = plain.Count(c => c >= 0xAC00 && c <= 0xD7A3);
return spaces + 1 + koreanChars / 3;
}
private static string StripHtmlTags(string html)
{
if (string.IsNullOrEmpty(html)) return "";
return System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ")
.Replace("&nbsp;", " ")
.Replace("&amp;", "&")
.Replace("&lt;", "<")
.Replace("&gt;", ">")
.Replace(" ", " ")
.Trim();
}
private static List<string> ValidateBasic(string html)
{
var issues = new List<string>();
if (html.Length < 500)
issues.Add("문서 내용이 매우 짧습니다 (500자 미만)");
// 빈 섹션 검사
var emptySectionPattern = new System.Text.RegularExpressions.Regex(
@"<h[23][^>]*>[^<]+</h[23]>\s*<div class=""section-content"">\s*</div>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
var emptyMatches = emptySectionPattern.Matches(html);
if (emptyMatches.Count > 0)
issues.Add($"빈 섹션 {emptyMatches.Count}개 발견");
// 플레이스홀더 검사
if (html.Contains("[TODO]", StringComparison.OrdinalIgnoreCase) ||
html.Contains("[PLACEHOLDER]", StringComparison.OrdinalIgnoreCase) ||
html.Contains("Lorem ipsum", StringComparison.OrdinalIgnoreCase))
issues.Add("플레이스홀더 텍스트가 남아있습니다");
return issues;
}
private static string Escape(string text)
{
return text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}
}

View File

@@ -0,0 +1,600 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 문서 개요(Outline)를 구조화된 JSON으로 생성하는 도구.
/// - 멀티패스(고품질) ON : 개요만 반환 → LLM이 섹션별로 상세 작성 → document_assemble로 조립
/// - 멀티패스(고품질) OFF: 개요 + 기본 문서를 즉시 로컬 파일로 저장 (LLM 호출 최소)
/// </summary>
public class DocumentPlannerTool : IAgentTool
{
private static bool IsMultiPassEnabled()
{
var app = System.Windows.Application.Current as App;
return app?.SettingsService?.Settings.Llm.EnableMultiPassDocument ?? false;
}
private static string GetFolderDataUsage()
{
var app = System.Windows.Application.Current as App;
return app?.SettingsService?.Settings.Llm.FolderDataUsage ?? "none";
}
public string Name => "document_plan";
public string Description =>
"Create a structured document outline/plan and optionally generate the document file immediately. " +
"Use this BEFORE generating long documents (3+ pages). " +
"When multi-pass mode is OFF, this tool directly creates and saves a document file with section headings and key points. " +
"When multi-pass mode is ON, returns a plan for section-by-section writing via document_assemble.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["topic"] = new()
{
Type = "string",
Description = "Document topic or full user request describing what the document should cover."
},
["document_type"] = new()
{
Type = "string",
Description = "Type of document: report, proposal, analysis, manual, minutes, presentation, guide. Default: report",
Enum = ["report", "proposal", "analysis", "manual", "minutes", "presentation", "guide"]
},
["target_pages"] = new()
{
Type = "integer",
Description = "Target number of pages (1 page ≈ 500 words). Default: 5"
},
["format"] = new()
{
Type = "string",
Description = "Output document format: html, docx, markdown. Default: html",
Enum = ["html", "docx", "markdown"]
},
["sections_hint"] = new()
{
Type = "string",
Description = "Optional hint for desired sections/structure (e.g. '서론, 현황분석, 문제점, 개선방안, 결론')"
},
["reference_summary"] = new()
{
Type = "string",
Description = "Optional summary of reference data already read via file_read (to incorporate into plan)"
},
},
Required = ["topic"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var topic = args.GetProperty("topic").GetString() ?? "";
var docType = args.TryGetProperty("document_type", out var dt) ? dt.GetString() ?? "report" : "report";
var targetPages = args.TryGetProperty("target_pages", out var tp) ? tp.GetInt32() : 5;
var format = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "html" : "html";
var sectionsHint = args.TryGetProperty("sections_hint", out var sh) ? sh.GetString() ?? "" : "";
var refSummary = args.TryGetProperty("reference_summary", out var rs) ? rs.GetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(topic))
return Task.FromResult(ToolResult.Fail("topic이 비어있습니다."));
if (targetPages < 1) targetPages = 1;
if (targetPages > 50) targetPages = 50;
var highQuality = IsMultiPassEnabled();
var folderDataUsage = GetFolderDataUsage();
// 고품질 모드: 목표 페이지와 단어 수를 1.5배 확장
var effectivePages = highQuality ? (int)Math.Ceiling(targetPages * 1.5) : targetPages;
var totalWords = effectivePages * 500;
var sections = BuildSections(docType, effectivePages, sectionsHint, refSummary);
DistributeWordCount(sections, totalWords);
// 폴더 데이터 활용 모드: 먼저 파일을 읽어야 하므로 별도 처리
if (folderDataUsage is "active" or "passive")
return ExecuteSinglePassWithData(topic, docType, format, effectivePages, totalWords, sections, folderDataUsage, refSummary);
// 일반/고품질 모드 모두 동일 구조:
// 개요 반환 + document_assemble 즉시 호출 지시
return ExecuteWithAssembleInstructions(topic, docType, format, effectivePages, totalWords, sections, highQuality);
}
// ─── 통합: 포맷별 body 골격 생성 + 즉시 호출 가능한 도구 파라미터 제시 ──────
private Task<ToolResult> ExecuteWithAssembleInstructions(string topic, string docType, string format,
int targetPages, int totalWords, List<SectionPlan> sections, bool highQuality)
{
var safeTitle = SanitizeFileName(topic);
var ext = format switch { "docx" => ".docx", "markdown" => ".md", _ => ".html" };
var suggestedFileName = $"{safeTitle}{ext}";
var label = highQuality ? "[고품질]" : "[표준]";
switch (format)
{
case "markdown":
return ExecuteWithMarkdownScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label);
case "docx":
return ExecuteWithDocxScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label);
default: // html
return ExecuteWithHtmlScaffold(topic, suggestedFileName, sections, targetPages, totalWords, label);
}
}
/// <summary>html_create 즉시 호출 가능한 body 골격 반환. 섹션 구조만 고정하고 내부 시각화는 LLM이 자유롭게 선택.</summary>
private Task<ToolResult> ExecuteWithHtmlScaffold(string topic, string fileName,
List<SectionPlan> sections, int targetPages, int totalWords, string label)
{
var bodySb = new StringBuilder();
foreach (var s in sections)
{
// 키포인트 내용을 분석해 적합한 HTML 요소를 제안 (강제 아님)
var elementHints = SuggestHtmlElements(s.KeyPoints);
bodySb.AppendLine($"<h2>{Escape(s.Heading)}</h2>");
bodySb.AppendLine($"<!-- 목표 {s.TargetWords}단어 | 핵심: {string.Join(", ", s.KeyPoints)}");
bodySb.AppendLine($" 활용 가능 요소(내용에 맞게 자유 선택): {elementHints} -->");
// 섹션 내부는 완전히 비워둠 — LLM이 내용과 구조를 모두 결정
bodySb.AppendLine();
}
var output = new StringBuilder();
output.AppendLine($"📋 문서 개요 생성 완료 {label} ({sections.Count}개 섹션, {targetPages}페이지/{totalWords}단어)");
output.AppendLine();
output.AppendLine("## 즉시 실행: html_create 호출");
output.AppendLine($"path: \"{fileName}\"");
output.AppendLine($"title: \"{topic}\"");
output.AppendLine("toc: true, numbered: true, mood: \"professional\"");
output.AppendLine($"cover: {{\"title\": \"{topic}\", \"author\": \"AX Copilot Agent\"}}");
output.AppendLine();
output.AppendLine("body에 아래 섹션 구조를 기반으로 각 섹션의 내용과 시각화를 자유롭게 작성하세요:");
output.AppendLine("(주석의 '활용 가능 요소'는 참고용이며, 내용에 맞게 다른 요소를 써도 됩니다)");
output.AppendLine();
output.AppendLine("--- body 시작 ---");
output.Append(bodySb);
output.AppendLine("--- body 끝 ---");
output.AppendLine();
output.AppendLine("⚠ html_create를 지금 즉시 호출하세요. 모든 섹션에 충분한 실제 내용을 작성하세요.");
return Task.FromResult(ToolResult.Ok(output.ToString()));
}
/// <summary>키포인트 키워드를 분석해 적합한 HTML 시각화 요소를 제안합니다.</summary>
private static string SuggestHtmlElements(List<string> keyPoints)
{
var joined = string.Join(" ", keyPoints).ToLowerInvariant();
var hints = new List<string>();
// 숫자/통계/비교 데이터 → 테이블 또는 차트
if (ContainsAny(joined, "매출", "실적", "통계", "수치", "비교", "현황", "지표", "점유율", "비율", "순위", "성과"))
{
hints.Add("table(데이터 정리)");
hints.Add("div.chart-bar(수치 시각화)");
}
// SWOT/강약점/리스크 → 그리드 + 콜아웃
if (ContainsAny(joined, "강점", "약점", "기회", "위협", "swot", "리스크", "문제점", "장점", "단점"))
{
hints.Add("div.grid-2(항목 대비)");
hints.Add("callout-warning/callout-tip");
}
// 전략/제안/방향 → 콜아웃 + 배지
if (ContainsAny(joined, "전략", "제안", "방안", "계획", "방향", "로드맵", "목표"))
{
hints.Add("callout-info(핵심 전략)");
hints.Add("span.badge-blue/green(구분 배지)");
}
// 일정/단계/프로세스 → 타임라인
if (ContainsAny(joined, "일정", "단계", "절차", "프로세스", "과정", "순서", "연혁", "역사"))
hints.Add("div.timeline(단계/일정)");
// 진행률/달성도 → 진행 바
if (ContainsAny(joined, "달성", "진행", "완료", "목표 대비", "진척"))
hints.Add("div.progress(달성률)");
// 카드형 항목 나열
if (ContainsAny(joined, "종류", "유형", "분류", "구성", "항목", "요소", "구분"))
hints.Add("div.grid-3.card(항목 카드)");
// 인용/핵심 메시지
if (ContainsAny(joined, "핵심", "요약", "결론", "시사점", "포인트"))
hints.Add("blockquote(핵심 메시지)");
// 기본 (항상 포함)
hints.Add("p/ul/ol(일반 서술)");
// 중복 제거, 최대 4개
return string.Join(" | ", hints.Distinct().Take(4));
}
private static bool ContainsAny(string text, params string[] keywords)
=> keywords.Any(k => text.Contains(k));
/// <summary>document_assemble 즉시 호출 가능한 sections 골격 반환 (docx용).</summary>
private Task<ToolResult> ExecuteWithDocxScaffold(string topic, string fileName,
List<SectionPlan> sections, int targetPages, int totalWords, string label)
{
var sectionsTemplate = sections.Select(s => new
{
heading = s.Heading,
content = $"[{s.Heading} 내용을 {s.TargetWords}단어 이상으로 작성. 핵심 항목: {string.Join(", ", s.KeyPoints)}]",
level = s.Level,
});
var json = JsonSerializer.Serialize(new
{
path = fileName,
title = topic,
format = "docx",
cover_subtitle = "AX Copilot Agent",
sections = sectionsTemplate,
}, _jsonOptions);
var output = new StringBuilder();
output.AppendLine($"📋 문서 개요 생성 완료 {label} ({sections.Count}개 섹션, {targetPages}페이지/{totalWords}단어)");
output.AppendLine("아래 document_assemble 파라미터에서 각 sections[].content의 [내용...] 부분을 실제 내용으로 채워서 즉시 호출하세요.");
output.AppendLine();
output.AppendLine("## 즉시 실행: document_assemble 호출 파라미터");
output.AppendLine(json);
output.AppendLine("⚠ 주의: 설명하지 말고 document_assemble 도구를 지금 즉시 호출하세요.");
return Task.FromResult(ToolResult.Ok(output.ToString()));
}
/// <summary>html_create(markdown body) 즉시 호출 가능한 골격 반환.</summary>
private Task<ToolResult> ExecuteWithMarkdownScaffold(string topic, string fileName,
List<SectionPlan> sections, int targetPages, int totalWords, string label)
{
var mdSb = new StringBuilder();
mdSb.AppendLine($"# {topic}");
mdSb.AppendLine();
mdSb.AppendLine($"*작성일: {DateTime.Now:yyyy-MM-dd}*");
mdSb.AppendLine();
foreach (var s in sections)
{
mdSb.AppendLine($"## {s.Heading}");
mdSb.AppendLine();
mdSb.AppendLine($"<!-- 목표 {s.TargetWords}단어 | 핵심: {string.Join(" / ", s.KeyPoints)} -->");
mdSb.AppendLine($"[{s.Heading} 내용을 {s.TargetWords}단어 이상으로 상세히 작성하세요]");
mdSb.AppendLine();
}
var output = new StringBuilder();
output.AppendLine($"📋 문서 개요 생성 완료 {label} ({sections.Count}개 섹션, {targetPages}페이지/{totalWords}단어)");
output.AppendLine("아래 file_write 파라미터에서 각 섹션의 [내용...] 부분을 실제 내용으로 채워서 즉시 호출하세요.");
output.AppendLine();
output.AppendLine("## 즉시 실행: file_write 호출");
output.AppendLine($"- path: \"{fileName}\"");
output.AppendLine("- content (각 [내용...] 부분을 실제 내용으로 교체):");
output.AppendLine(mdSb.ToString());
output.AppendLine("⚠ 주의: 설명하지 말고 file_write 도구를 지금 즉시 호출하세요. 모든 섹션에 실제 내용을 작성하세요.");
return Task.FromResult(ToolResult.Ok(output.ToString()));
}
// ─── 싱글패스 + 폴더 데이터 활용: 개요 반환 + LLM이 데이터 읽고 직접 저장 ──
private Task<ToolResult> ExecuteSinglePassWithData(string topic, string docType, string format,
int targetPages, int totalWords, List<SectionPlan> sections, string folderDataUsage, string refSummary)
{
var ext = format switch { "docx" => ".docx", "markdown" => ".md", _ => ".html" };
var createTool = format switch { "docx" => "docx_create", "markdown" => "file_write", _ => "html_create" };
var safeTitle = SanitizeFileName(topic);
var suggestedPath = $"{safeTitle}{ext}";
// reference_summary가 이미 있으면 데이터 읽기 단계를 건너뛸 수 있음
var hasRefData = !string.IsNullOrWhiteSpace(refSummary);
var plan = new
{
topic,
document_type = docType,
format,
target_pages = targetPages,
estimated_total_words = totalWords,
suggested_file_name = suggestedPath,
sections,
reference_data = hasRefData ? refSummary : null as string,
instructions = new
{
mode = "single-pass-with-data",
folder_data_usage = folderDataUsage,
step1 = hasRefData
? "Reference data is already provided above. Skip to step 3."
: "Use folder_map to scan the work folder, then use document_read to read RELEVANT files only.",
step2 = hasRefData
? "(skipped)"
: "Summarize the key findings from the folder documents relevant to the topic.",
step3 = $"Write the COMPLETE document content covering ALL sections above, incorporating folder data.",
step4 = $"Save the document using {createTool} with the path '{suggestedPath}'. " +
"Write ALL sections in a SINGLE tool call. Do NOT split into multiple calls.",
note = "Minimize LLM calls. Read data → write complete document → save. Maximum 3 iterations."
}
};
var json = JsonSerializer.Serialize(plan, _jsonOptions);
var dataNote = hasRefData
? "참조 데이터가 이미 제공되었습니다. 바로 문서를 작성하세요."
: $"폴더 데이터 활용 모드({folderDataUsage})가 활성화되어 있습니다. 먼저 관련 데이터를 읽은 후 문서를 작성하세요.";
return Task.FromResult(ToolResult.Ok(
$"📋 문서 개요가 생성되었습니다 [싱글패스+데이터활용] ({sections.Count}개 섹션, 목표 {targetPages}페이지/{totalWords}단어).\n" +
$"{dataNote}\n" +
$"작성 완료 후 {createTool}로 '{suggestedPath}' 파일에 저장하세요.\n\n{json}"));
}
// ─── HTML 생성 ───────────────────────────────────────────────────────────
private static void GenerateHtml(string path, string title, string docType, List<SectionPlan> sections)
{
var css = TemplateService.GetCss("professional");
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"ko\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"utf-8\">");
sb.AppendLine($"<title>{Escape(title)}</title>");
sb.AppendLine("<style>");
sb.AppendLine(css);
sb.AppendLine(@"
.doc { max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }
.doc h1 { font-size: 28px; margin-bottom: 8px; border-bottom: 3px solid var(--accent, #4B5EFC); padding-bottom: 10px; }
.doc h2 { font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }
.doc .meta { color: #888; font-size: 13px; margin-bottom: 24px; }
.doc .section { line-height: 1.8; margin-bottom: 20px; }
.doc .toc { background: #f8f9fa; border-radius: 12px; padding: 20px 28px; margin: 24px 0 32px; }
.doc .toc h3 { margin-top: 0; }
.doc .toc a { color: inherit; text-decoration: none; }
.doc .toc a:hover { text-decoration: underline; }
.doc .toc ul { list-style: none; padding-left: 0; }
.doc .toc li { padding: 4px 0; }
.doc .key-point { background: #f0f4ff; border-left: 4px solid var(--accent, #4B5EFC); padding: 12px 16px; margin: 12px 0; border-radius: 0 8px 8px 0; }
@media print { .doc h2 { page-break-before: auto; } }
");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.AppendLine("<div class=\"doc\">");
sb.AppendLine($"<h1>{Escape(title)}</h1>");
sb.AppendLine($"<div class=\"meta\">문서 유형: {Escape(GetDocTypeLabel(docType))} | 작성일: {DateTime.Now:yyyy-MM-dd} | 섹션: {sections.Count}개</div>");
// 목차
if (sections.Count > 1)
{
sb.AppendLine("<div class=\"toc\">");
sb.AppendLine("<h3>📋 목차</h3>");
sb.AppendLine("<ul>");
for (int i = 0; i < sections.Count; i++)
sb.AppendLine($"<li><a href=\"#sec-{i + 1}\">{Escape(sections[i].Heading)}</a></li>");
sb.AppendLine("</ul>");
sb.AppendLine("</div>");
}
// 섹션 본문
for (int i = 0; i < sections.Count; i++)
{
var sec = sections[i];
sb.AppendLine($"<h2 id=\"sec-{i + 1}\">{Escape(sec.Heading)}</h2>");
sb.AppendLine("<div class=\"section\">");
foreach (var kp in sec.KeyPoints)
{
sb.AppendLine($"<div class=\"key-point\">");
sb.AppendLine($"<strong>▸ {Escape(kp)}</strong>");
sb.AppendLine($"<p>{Escape(kp)}에 대한 상세 내용을 여기에 작성합니다. (목표: 약 {sec.TargetWords / Math.Max(1, sec.KeyPoints.Count)}단어)</p>");
sb.AppendLine("</div>");
}
sb.AppendLine("</div>");
}
sb.AppendLine("</div>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
}
// ─── DOCX 생성 ──────────────────────────────────────────────────────────
private static void GenerateDocx(string path, string title, List<SectionPlan> sections)
{
using var doc = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Create(
path, DocumentFormat.OpenXml.WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
var body = mainPart.Document.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Body());
// 제목
AddDocxParagraph(body, title, bold: true, fontSize: "48");
AddDocxParagraph(body, $"작성일: {DateTime.Now:yyyy-MM-dd}", fontSize: "20", color: "888888");
AddDocxParagraph(body, ""); // 빈 줄
foreach (var sec in sections)
{
AddDocxParagraph(body, sec.Heading, bold: true, fontSize: "32", color: "2B579A");
foreach (var kp in sec.KeyPoints)
{
AddDocxParagraph(body, $"▸ {kp}", bold: true, fontSize: "22");
AddDocxParagraph(body, $"{kp}에 대한 상세 내용을 여기에 작성합니다.");
}
AddDocxParagraph(body, ""); // 섹션 간 빈 줄
}
}
private static void AddDocxParagraph(DocumentFormat.OpenXml.Wordprocessing.Body body,
string text, bool bold = false, string fontSize = "22", string? color = null)
{
var para = new DocumentFormat.OpenXml.Wordprocessing.Paragraph();
var run = new DocumentFormat.OpenXml.Wordprocessing.Run();
var props = new DocumentFormat.OpenXml.Wordprocessing.RunProperties
{
FontSize = new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = fontSize }
};
if (bold) props.Bold = new DocumentFormat.OpenXml.Wordprocessing.Bold();
if (color != null) props.Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = color };
run.AppendChild(props);
run.AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Text(text)
{
Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve
});
para.AppendChild(run);
body.AppendChild(para);
}
// ─── Markdown 생성 ──────────────────────────────────────────────────────
private static void GenerateMarkdown(string path, string title, List<SectionPlan> sections)
{
var sb = new StringBuilder();
sb.AppendLine($"# {title}");
sb.AppendLine();
sb.AppendLine($"*작성일: {DateTime.Now:yyyy-MM-dd}*");
sb.AppendLine();
// 목차
if (sections.Count > 1)
{
sb.AppendLine("## 목차");
sb.AppendLine();
foreach (var sec in sections)
sb.AppendLine($"- [{sec.Heading}](#{sec.Heading.Replace(" ", "-").ToLowerInvariant()})");
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine();
}
foreach (var sec in sections)
{
sb.AppendLine($"## {sec.Heading}");
sb.AppendLine();
foreach (var kp in sec.KeyPoints)
{
sb.AppendLine($"### ▸ {kp}");
sb.AppendLine();
sb.AppendLine($"{kp}에 대한 상세 내용을 여기에 작성합니다.");
sb.AppendLine();
}
}
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
}
// ─── 헬퍼 ────────────────────────────────────────────────────────────────
private static List<SectionPlan> BuildSections(string docType, int pages, string hint, string refSummary)
{
if (!string.IsNullOrWhiteSpace(hint))
{
var hintSections = hint.Split(new[] { ',', '/', '→', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var result = new List<SectionPlan>();
for (int i = 0; i < hintSections.Length; i++)
{
result.Add(new SectionPlan
{
Id = $"sec-{i + 1}",
Heading = $"{i + 1}. {hintSections[i].TrimStart("0123456789. ".ToCharArray())}",
Level = 1,
KeyPoints = [$"{hintSections[i]} 관련 핵심 내용을 상세히 작성"],
});
}
return result;
}
return docType switch
{
"proposal" => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. 개요", Level = 1, KeyPoints = ["배경", "목적", "범위"] },
new() { Id = "sec-2", Heading = "2. 현황 분석", Level = 1, KeyPoints = ["현재 상황", "문제점 식별"] },
new() { Id = "sec-3", Heading = "3. 제안 내용", Level = 1, KeyPoints = ["핵심 제안", "기대 효과", "실행 방안"] },
new() { Id = "sec-4", Heading = "4. 추진 일정", Level = 1, KeyPoints = ["단계별 일정", "마일스톤"] },
new() { Id = "sec-5", Heading = "5. 소요 자원", Level = 1, KeyPoints = ["인력", "예산", "장비"] },
new() { Id = "sec-6", Heading = "6. 기대 효과 및 결론", Level = 1, KeyPoints = ["정량적 효과", "정성적 효과", "결론"] },
},
"analysis" => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. 분석 개요", Level = 1, KeyPoints = ["분석 목적", "분석 범위", "방법론"] },
new() { Id = "sec-2", Heading = "2. 데이터 현황", Level = 1, KeyPoints = ["데이터 출처", "기본 통계", "데이터 품질"] },
new() { Id = "sec-3", Heading = "3. 정량 분석", Level = 1, KeyPoints = ["수치 분석", "추세", "비교"] },
new() { Id = "sec-4", Heading = "4. 정성 분석", Level = 1, KeyPoints = ["패턴", "인사이트", "이상치"] },
new() { Id = "sec-5", Heading = "5. 종합 해석", Level = 1, KeyPoints = ["핵심 발견", "시사점"] },
new() { Id = "sec-6", Heading = "6. 결론 및 권장사항", Level = 1, KeyPoints = ["결론", "조치 방안", "추가 분석 필요 항목"] },
},
"manual" or "guide" => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. 소개", Level = 1, KeyPoints = ["목적", "대상 독자", "사전 요구사항"] },
new() { Id = "sec-2", Heading = "2. 시작하기", Level = 1, KeyPoints = ["설치", "초기 설정", "기본 사용법"] },
new() { Id = "sec-3", Heading = "3. 주요 기능", Level = 1, KeyPoints = ["기능별 상세 설명", "사용 예시"] },
new() { Id = "sec-4", Heading = "4. 고급 기능", Level = 1, KeyPoints = ["고급 설정", "커스터마이징", "자동화"] },
new() { Id = "sec-5", Heading = "5. 문제 해결", Level = 1, KeyPoints = ["자주 묻는 질문", "에러 대응", "지원 정보"] },
},
"minutes" => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. 회의 정보", Level = 1, KeyPoints = ["일시", "참석자", "장소"] },
new() { Id = "sec-2", Heading = "2. 안건 및 논의", Level = 1, KeyPoints = ["안건별 논의 내용", "주요 의견"] },
new() { Id = "sec-3", Heading = "3. 결정 사항", Level = 1, KeyPoints = ["합의 내용", "변경 사항"] },
new() { Id = "sec-4", Heading = "4. 액션 아이템", Level = 1, KeyPoints = ["담당자", "기한", "세부 내용"] },
new() { Id = "sec-5", Heading = "5. 다음 회의", Level = 1, KeyPoints = ["예정일", "주요 안건"] },
},
_ => new List<SectionPlan>
{
new() { Id = "sec-1", Heading = "1. 개요", Level = 1, KeyPoints = ["배경", "목적", "범위"] },
new() { Id = "sec-2", Heading = "2. 현황", Level = 1, KeyPoints = ["현재 상태", "주요 지표"] },
new() { Id = "sec-3", Heading = "3. 분석", Level = 1, KeyPoints = ["데이터 분석", "비교", "추세"] },
new() { Id = "sec-4", Heading = "4. 주요 발견", Level = 1, KeyPoints = ["핵심 인사이트", "문제점", "기회"] },
new() { Id = "sec-5", Heading = "5. 제안", Level = 1, KeyPoints = ["개선 방안", "실행 계획"] },
new() { Id = "sec-6", Heading = "6. 결론", Level = 1, KeyPoints = ["요약", "기대 효과", "향후 과제"] },
},
};
}
private static void DistributeWordCount(List<SectionPlan> sections, int totalWords)
{
if (sections.Count == 0) return;
var weights = new double[sections.Count];
for (int i = 0; i < sections.Count; i++)
weights[i] = (i == 0 || i == sections.Count - 1) ? 0.7 : 1.2;
var totalWeight = weights.Sum();
for (int i = 0; i < sections.Count; i++)
sections[i].TargetWords = Math.Max(100, (int)(totalWords * weights[i] / totalWeight));
}
private static string SanitizeFileName(string name)
{
var safe = name.Length > 60 ? name[..60] : name;
foreach (var c in Path.GetInvalidFileNameChars())
safe = safe.Replace(c, '_');
return safe.Trim().TrimEnd('.');
}
private static string GetDocTypeLabel(string docType) => docType switch
{
"proposal" => "제안서",
"analysis" => "분석 보고서",
"manual" or "guide" => "매뉴얼/가이드",
"minutes" => "회의록",
"presentation" => "프레젠테이션",
_ => "보고서",
};
private static string Escape(string text)
=> text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
private static readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private class SectionPlan
{
public string Id { get; set; } = "";
public string Heading { get; set; } = "";
public int Level { get; set; } = 1;
public int TargetWords { get; set; } = 300;
public List<string> KeyPoints { get; set; } = new();
}
}

View File

@@ -0,0 +1,571 @@
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;
using UglyToad.PdfPig;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 문서 파일을 읽어 텍스트로 반환하는 도구.
/// PDF, DOCX, XLSX, CSV, TXT, BibTeX, RIS 등 다양한 형식을 지원합니다.
/// </summary>
public class DocumentReaderTool : IAgentTool
{
public string Name => "document_read";
public string Description =>
"Read a document file and extract its text content. " +
"Supports: PDF (.pdf), Word (.docx), Excel (.xlsx), CSV (.csv), text (.txt/.log/.json/.xml/.md), " +
"BibTeX (.bib), RIS (.ris). " +
"For large files, use 'offset' to read from a specific character position (chunked reading). " +
"For large PDFs, use 'pages' parameter to read specific page ranges (e.g., '1-5', '10-20'). " +
"Use 'section' parameter with value 'references' to extract only the references/bibliography section from a PDF.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "Document file path (absolute or relative to work folder)" },
["max_chars"] = new() { Type = "integer", Description = "Maximum characters to extract per chunk. Default: 8000. Use smaller values for summaries." },
["offset"] = new() { Type = "integer", Description = "Character offset to start reading from. Default: 0. Use this to read the next chunk of a large file (value from 'next_offset' in previous response)." },
["sheet"] = new() { Type = "string", Description = "For Excel files: sheet name or 1-based index. Default: first sheet." },
["pages"] = new() { Type = "string", Description = "For PDF files: page range to read (e.g., '1-5', '3', '10-20'). Default: all pages." },
["section"] = new() { Type = "string", Description = "Extract specific section. 'references' = extract references/bibliography from PDF. 'abstract' = extract abstract." },
},
Required = ["path"]
};
private static readonly HashSet<string> TextExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".txt", ".log", ".json", ".xml", ".md", ".csv", ".tsv",
".yaml", ".yml", ".ini", ".cfg", ".conf", ".properties",
".html", ".htm", ".css", ".js", ".ts", ".py", ".cs", ".java",
".sql", ".sh", ".bat", ".ps1", ".r", ".m",
};
private const int DefaultMaxChars = 8000;
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("path", out var pathEl))
return ToolResult.Fail("path가 필요합니다.");
var path = pathEl.GetString() ?? "";
var maxChars = args.TryGetProperty("max_chars", out var mc) ? GetIntValue(mc, DefaultMaxChars) : DefaultMaxChars;
var offset = args.TryGetProperty("offset", out var off) ? GetIntValue(off, 0) : 0;
var sheetParam = args.TryGetProperty("sheet", out var sh) ? sh.GetString() ?? "" : "";
var pagesParam = args.TryGetProperty("pages", out var pg) ? pg.GetString() ?? "" : "";
var sectionParam = args.TryGetProperty("section", out var sec) ? sec.GetString() ?? "" : "";
if (maxChars < 100) maxChars = DefaultMaxChars;
if (offset < 0) offset = 0;
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
if (!File.Exists(fullPath))
return ToolResult.Fail($"파일이 존재하지 않습니다: {fullPath}");
var ext = Path.GetExtension(fullPath).ToLowerInvariant();
try
{
// 전체 텍스트 추출 (offset > 0이면 전체를 추출해서 잘라야 함)
var extractMax = offset > 0 ? offset + maxChars + 100 : maxChars;
var text = ext switch
{
".pdf" => await Task.Run(() => ReadPdf(fullPath, extractMax, pagesParam, sectionParam), ct),
".docx" => await Task.Run(() => ReadDocx(fullPath, extractMax), ct),
".xlsx" => await Task.Run(() => ReadXlsx(fullPath, sheetParam, extractMax), ct),
".bib" => await Task.Run(() => ReadBibTeX(fullPath, extractMax), ct),
".ris" => await Task.Run(() => ReadRis(fullPath, extractMax), ct),
_ when TextExtensions.Contains(ext) => await ReadTextFile(fullPath, extractMax, ct),
_ => null,
};
if (text == null)
return ToolResult.Fail($"지원하지 않는 파일 형식: {ext}");
var totalExtracted = text.Length;
// offset 적용 — 청크 분할 읽기
if (offset > 0)
{
if (offset >= text.Length)
return ToolResult.Ok($"[{Path.GetFileName(fullPath)}] offset {offset}은 문서 끝을 초과합니다 (전체 {text.Length}자).", fullPath);
text = text[offset..];
}
// maxChars 자르기
var hasMore = text.Length > maxChars;
if (hasMore)
text = text[..maxChars];
var fileInfo = new FileInfo(fullPath);
var header = $"[{Path.GetFileName(fullPath)}] ({ext.TrimStart('.')}, {FormatSize(fileInfo.Length)})";
if (offset > 0)
header += $" — offset {offset}부터 {maxChars}자 읽음";
if (hasMore)
{
var nextOffset = offset + maxChars;
header += $"\n⚡ 추가 내용이 있습니다. 다음 청크를 읽으려면 offset={nextOffset}을 사용하세요.";
}
else if (offset > 0)
{
header += " — 문서 끝까지 읽음 ✓";
}
else if (totalExtracted >= maxChars)
{
header += $" — 처음 {maxChars}자만 추출됨. 계속 읽으려면 offset={maxChars}을 사용하거나, pages 파라미터로 특정 페이지를 지정하세요.";
}
return ToolResult.Ok($"{header}\n\n{text}", fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"문서 읽기 실패: {ex.Message}");
}
}
// ─── PDF ────────────────────────────────────────────────────────────────
private static string ReadPdf(string path, int maxChars, string pagesParam, string sectionParam)
{
var sb = new StringBuilder();
using var doc = PdfDocument.Open(path);
var totalPages = doc.NumberOfPages;
sb.AppendLine($"PDF: {totalPages}페이지");
sb.AppendLine();
// 페이지 범위 파싱
var (startPage, endPage) = ParsePageRange(pagesParam, totalPages);
// 섹션 추출 모드
if (string.Equals(sectionParam, "references", StringComparison.OrdinalIgnoreCase))
return ExtractReferences(doc, totalPages, maxChars);
if (string.Equals(sectionParam, "abstract", StringComparison.OrdinalIgnoreCase))
return ExtractAbstract(doc, totalPages, maxChars);
sb.AppendLine($"읽는 범위: {startPage}-{endPage} / {totalPages} 페이지");
sb.AppendLine();
for (int i = startPage; i <= endPage && sb.Length < maxChars; i++)
{
var page = doc.GetPage(i);
var pageText = page.Text;
if (!string.IsNullOrWhiteSpace(pageText))
{
sb.AppendLine($"--- Page {i} ---");
sb.AppendLine(pageText.Trim());
sb.AppendLine();
}
}
return Truncate(sb.ToString(), maxChars);
}
private static (int start, int end) ParsePageRange(string pagesParam, int totalPages)
{
if (string.IsNullOrWhiteSpace(pagesParam))
return (1, totalPages);
// "5" → page 5 only
if (int.TryParse(pagesParam.Trim(), out var single))
return (Math.Max(1, single), Math.Min(single, totalPages));
// "3-10" → pages 3 to 10
var parts = pagesParam.Split('-', StringSplitOptions.TrimEntries);
if (parts.Length == 2 &&
int.TryParse(parts[0], out var s) &&
int.TryParse(parts[1], out var e))
{
return (Math.Max(1, s), Math.Min(e, totalPages));
}
return (1, totalPages);
}
/// <summary>PDF에서 References/Bibliography 섹션을 추출합니다.</summary>
private static string ExtractReferences(PdfDocument doc, int totalPages, int maxChars)
{
var sb = new StringBuilder();
sb.AppendLine("=== References / Bibliography ===");
sb.AppendLine();
// 뒤에서부터 References 섹션 시작점을 찾습니다
var refPatterns = new[]
{
@"(?i)^\s*(References|Bibliography|Works\s+Cited|Literature\s+Cited|참고\s*문헌|참조|인용\s*문헌)\s*$",
@"(?i)^(References|Bibliography|참고문헌)\s*\n",
};
bool found = false;
for (int i = totalPages; i >= Math.Max(1, totalPages - 10) && !found; i--)
{
var pageText = doc.GetPage(i).Text;
if (string.IsNullOrWhiteSpace(pageText)) continue;
foreach (var pattern in refPatterns)
{
var match = Regex.Match(pageText, pattern, RegexOptions.Multiline);
if (match.Success)
{
// References 시작 지점부터 끝까지 추출
var refStart = match.Index;
sb.AppendLine($"(Page {i}부터 시작)");
sb.AppendLine(pageText[refStart..].Trim());
// 이후 페이지도 포함
for (int j = i + 1; j <= totalPages && sb.Length < maxChars; j++)
{
var nextText = doc.GetPage(j).Text;
if (!string.IsNullOrWhiteSpace(nextText))
sb.AppendLine(nextText.Trim());
}
found = true;
break;
}
}
}
if (!found)
{
// References 헤더를 못 찾으면 마지막 3페이지를 반환
sb.AppendLine("(References 섹션 헤더를 찾지 못했습니다. 마지막 3페이지를 반환합니다.)");
sb.AppendLine();
for (int i = Math.Max(1, totalPages - 2); i <= totalPages && sb.Length < maxChars; i++)
{
var pageText = doc.GetPage(i).Text;
if (!string.IsNullOrWhiteSpace(pageText))
{
sb.AppendLine($"--- Page {i} ---");
sb.AppendLine(pageText.Trim());
sb.AppendLine();
}
}
}
// 개별 참고문헌 항목 파싱 시도
var rawRefs = sb.ToString();
var parsed = ParseReferenceEntries(rawRefs);
if (parsed.Count > 0)
{
var result = new StringBuilder();
result.AppendLine($"=== References ({parsed.Count}개 항목) ===\n");
for (int i = 0; i < parsed.Count; i++)
{
result.AppendLine($"[{i + 1}] {parsed[i]}");
}
return Truncate(result.ToString(), maxChars);
}
return Truncate(rawRefs, maxChars);
}
/// <summary>PDF에서 Abstract 섹션을 추출합니다.</summary>
private static string ExtractAbstract(PdfDocument doc, int totalPages, int maxChars)
{
var sb = new StringBuilder();
sb.AppendLine("=== Abstract ===");
sb.AppendLine();
// 첫 3페이지에서 Abstract 찾기
for (int i = 1; i <= Math.Min(3, totalPages); i++)
{
var pageText = doc.GetPage(i).Text;
if (string.IsNullOrWhiteSpace(pageText)) continue;
var match = Regex.Match(pageText,
@"(?i)(Abstract|초록|요약)\s*\n(.*?)(?=\n\s*(Keywords|Introduction|1\.|서론|키워드|핵심어)\s*[\n:])",
RegexOptions.Singleline);
if (match.Success)
{
sb.AppendLine(match.Groups[2].Value.Trim());
return Truncate(sb.ToString(), maxChars);
}
}
// 찾지 못하면 첫 페이지 반환
sb.AppendLine("(Abstract 섹션을 찾지 못했습니다. 첫 페이지를 반환합니다.)");
var firstPage = doc.GetPage(1).Text;
if (!string.IsNullOrWhiteSpace(firstPage))
sb.AppendLine(firstPage.Trim());
return Truncate(sb.ToString(), maxChars);
}
/// <summary>참고문헌 텍스트에서 개별 항목을 파싱합니다.</summary>
private static List<string> ParseReferenceEntries(string text)
{
var entries = new List<string>();
// [1], [2] 형태의 번호 매기기
var numbered = Regex.Split(text, @"\n\s*\[(\d+)\]\s*");
if (numbered.Length > 3)
{
for (int i = 2; i < numbered.Length; i += 2)
{
var entry = numbered[i].Trim().Replace("\n", " ").Replace(" ", " ");
if (entry.Length > 10)
entries.Add(entry);
}
return entries;
}
// 1. 2. 3. 형태
var dotNumbered = Regex.Split(text, @"\n\s*(\d+)\.\s+");
if (dotNumbered.Length > 5)
{
for (int i = 2; i < dotNumbered.Length; i += 2)
{
var entry = dotNumbered[i].Trim().Replace("\n", " ").Replace(" ", " ");
if (entry.Length > 10)
entries.Add(entry);
}
return entries;
}
return entries;
}
// ─── BibTeX ─────────────────────────────────────────────────────────────
private static string ReadBibTeX(string path, int maxChars)
{
var content = TextFileCodec.ReadAllText(path).Text;
var sb = new StringBuilder();
var entryPattern = new Regex(
@"@(\w+)\s*\{\s*([^,\s]+)\s*,\s*(.*?)\n\s*\}",
RegexOptions.Singleline);
var fieldPattern = new Regex(
@"(\w+)\s*=\s*[\{""](.*?)[\}""]",
RegexOptions.Singleline);
var matches = entryPattern.Matches(content);
sb.AppendLine($"BibTeX: {matches.Count}개 항목");
sb.AppendLine();
int idx = 0;
foreach (Match m in matches)
{
if (sb.Length >= maxChars) break;
idx++;
var entryType = m.Groups[1].Value;
var citeKey = m.Groups[2].Value;
var body = m.Groups[3].Value;
sb.AppendLine($"[{idx}] @{entryType}{{{citeKey}}}");
var fields = fieldPattern.Matches(body);
foreach (Match f in fields)
{
var fieldName = f.Groups[1].Value.ToLower();
var fieldValue = f.Groups[2].Value.Trim();
// 핵심 필드만 표시
if (fieldName is "author" or "title" or "journal" or "booktitle"
or "year" or "volume" or "number" or "pages" or "doi"
or "publisher" or "url")
{
sb.AppendLine($" {fieldName}: {fieldValue}");
}
}
sb.AppendLine();
}
if (matches.Count == 0)
{
sb.AppendLine("(BibTeX 항목을 파싱하지 못했습니다. 원문을 반환합니다.)");
sb.AppendLine(Truncate(content, maxChars - sb.Length));
}
return Truncate(sb.ToString(), maxChars);
}
// ─── RIS ────────────────────────────────────────────────────────────────
private static string ReadRis(string path, int maxChars)
{
var lines = TextFileCodec.SplitLines(TextFileCodec.ReadAllText(path).Text);
var sb = new StringBuilder();
var entries = new List<Dictionary<string, List<string>>>();
Dictionary<string, List<string>>? current = null;
foreach (var line in lines)
{
if (line.StartsWith("TY -"))
{
current = new Dictionary<string, List<string>>();
entries.Add(current);
}
else if (line.StartsWith("ER -"))
{
current = null;
continue;
}
if (current != null && line.Length >= 6 && line[2] == ' ' && line[3] == ' ' && line[4] == '-' && line[5] == ' ')
{
var tag = line[..2].Trim();
var value = line[6..].Trim();
if (!current.ContainsKey(tag))
current[tag] = new List<string>();
current[tag].Add(value);
}
}
sb.AppendLine($"RIS: {entries.Count}개 항목");
sb.AppendLine();
// RIS 태그 → 사람이 읽을 수 있는 이름
var tagNames = new Dictionary<string, string>
{
["TY"] = "Type", ["AU"] = "Author", ["TI"] = "Title", ["T1"] = "Title",
["JO"] = "Journal", ["JF"] = "Journal", ["PY"] = "Year", ["Y1"] = "Year",
["VL"] = "Volume", ["IS"] = "Issue", ["SP"] = "Start Page", ["EP"] = "End Page",
["DO"] = "DOI", ["UR"] = "URL", ["PB"] = "Publisher", ["AB"] = "Abstract",
["KW"] = "Keyword", ["SN"] = "ISSN/ISBN",
};
for (int i = 0; i < entries.Count && sb.Length < maxChars; i++)
{
sb.AppendLine($"[{i + 1}]");
var entry = entries[i];
foreach (var (tag, values) in entry)
{
var label = tagNames.GetValueOrDefault(tag, tag);
if (tag is "AU" or "KW")
sb.AppendLine($" {label}: {string.Join("; ", values)}");
else
sb.AppendLine($" {label}: {string.Join(" ", values)}");
}
sb.AppendLine();
}
return Truncate(sb.ToString(), maxChars);
}
// ─── DOCX ───────────────────────────────────────────────────────────────
private static string ReadDocx(string path, int maxChars)
{
var sb = new StringBuilder();
using var doc = WordprocessingDocument.Open(path, false);
var body = doc.MainDocumentPart?.Document.Body;
if (body == null) return "(빈 문서)";
foreach (var para in body.Elements<DocumentFormat.OpenXml.Wordprocessing.Paragraph>())
{
var text = para.InnerText;
if (!string.IsNullOrWhiteSpace(text))
{
sb.AppendLine(text);
if (sb.Length >= maxChars) break;
}
}
return Truncate(sb.ToString(), maxChars);
}
// ─── XLSX ───────────────────────────────────────────────────────────────
private static string ReadXlsx(string path, string sheetParam, int maxChars)
{
var sb = new StringBuilder();
using var doc = SpreadsheetDocument.Open(path, false);
var workbook = doc.WorkbookPart;
if (workbook == null) return "(빈 스프레드시트)";
var sheets = workbook.Workbook.Sheets?.Elements<Sheet>().ToList() ?? [];
if (sheets.Count == 0) return "(시트 없음)";
sb.AppendLine($"Excel: {sheets.Count}개 시트 ({string.Join(", ", sheets.Select(s => s.Name?.Value))})");
sb.AppendLine();
Sheet? targetSheet = null;
if (!string.IsNullOrEmpty(sheetParam))
{
if (int.TryParse(sheetParam, out var idx) && idx >= 1 && idx <= sheets.Count)
targetSheet = sheets[idx - 1];
else
targetSheet = sheets.FirstOrDefault(s =>
string.Equals(s.Name?.Value, sheetParam, StringComparison.OrdinalIgnoreCase));
}
targetSheet ??= sheets[0];
var sheetId = targetSheet.Id?.Value;
if (sheetId == null) return "(시트 ID 없음)";
var wsPart = (WorksheetPart)workbook.GetPartById(sheetId);
var sharedStrings = workbook.SharedStringTablePart?.SharedStringTable
.Elements<SharedStringItem>().ToList() ?? [];
var rows = wsPart.Worksheet.Descendants<Row>().ToList();
sb.AppendLine($"[{targetSheet.Name?.Value}] ({rows.Count} rows)");
foreach (var row in rows)
{
var cells = row.Elements<Cell>().ToList();
var values = new List<string>();
foreach (var cell in cells)
values.Add(GetCellValue(cell, sharedStrings));
sb.AppendLine(string.Join("\t", values));
if (sb.Length >= maxChars) break;
}
return Truncate(sb.ToString(), maxChars);
}
private static string GetCellValue(Cell cell, List<SharedStringItem> sharedStrings)
{
var value = cell.CellValue?.Text ?? "";
if (cell.DataType?.Value == CellValues.SharedString)
{
if (int.TryParse(value, out var idx) && idx >= 0 && idx < sharedStrings.Count)
return sharedStrings[idx].InnerText;
}
return value;
}
// ─── Text ───────────────────────────────────────────────────────────────
private static async Task<string> ReadTextFile(string path, int maxChars, CancellationToken ct)
{
var text = (await TextFileCodec.ReadAllTextAsync(path, ct)).Text;
return Truncate(text, maxChars);
}
// ─── Helpers ────────────────────────────────────────────────────────────
private static string Truncate(string text, int maxChars)
{
if (text.Length <= maxChars) return text;
return text[..maxChars] + "\n\n... (내용 잘림 — pages 또는 section 파라미터로 특정 부분을 읽을 수 있습니다)";
}
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",
};
/// <summary>JsonElement에서 int를 안전하게 추출합니다. string/integer 양쪽 호환.</summary>
private static int GetIntValue(JsonElement el, int defaultValue)
{
if (el.ValueKind == JsonValueKind.Number) return el.GetInt32();
if (el.ValueKind == JsonValueKind.String && int.TryParse(el.GetString(), out var v)) return v;
return defaultValue;
}
}

View File

@@ -0,0 +1,161 @@
using System.IO;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 생성된 문서를 자동 검증하는 도구.
/// HTML/Markdown/텍스트 파일의 구조적 완성도, 날짜 정합성, 빈 섹션 등을 점검합니다.
/// </summary>
public class DocumentReviewTool : IAgentTool
{
public string Name => "document_review";
public string Description =>
"Review a generated document for quality issues. " +
"Checks: empty sections, placeholder text, date consistency, missing headings, broken HTML tags, " +
"content completeness. Returns a structured review report with issues found and suggestions.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "Path to the document to review" },
["expected_sections"] = new()
{
Type = "array",
Description = "Optional list of expected section titles to verify presence",
Items = new() { Type = "string" },
},
},
Required = ["path"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("path", out var pathEl))
return Task.FromResult(ToolResult.Fail("path가 필요합니다."));
var path = pathEl.GetString() ?? "";
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {fullPath}"));
if (!File.Exists(fullPath))
return Task.FromResult(ToolResult.Fail($"파일 없음: {fullPath}"));
var content = TextFileCodec.ReadAllText(fullPath).Text;
var ext = Path.GetExtension(fullPath).ToLowerInvariant();
var issues = new List<string>();
var stats = new List<string>();
// 기본 통계
var lineCount = content.Split('\n').Length;
var charCount = content.Length;
stats.Add($"파일: {Path.GetFileName(fullPath)} ({charCount:N0}자, {lineCount}줄)");
// 1. 빈 콘텐츠 검사
if (string.IsNullOrWhiteSpace(content))
{
issues.Add("[CRITICAL] 파일 내용이 비어있습니다");
return Task.FromResult(ToolResult.Ok(FormatReport(stats, issues, []), fullPath));
}
// 2. 플레이스홀더 텍스트 검사
var placeholders = new[] { "TODO", "TBD", "FIXME", "Lorem ipsum", "[여기에", "[INSERT", "placeholder", "예시 텍스트" };
foreach (var ph in placeholders)
{
if (content.Contains(ph, StringComparison.OrdinalIgnoreCase))
issues.Add($"[WARNING] 플레이스홀더 텍스트 발견: \"{ph}\"");
}
// 3. 날짜 정합성 (미래 날짜, 너무 오래된 날짜)
var datePattern = new Regex(@"\d{4}[-년.]\s*\d{1,2}[-월.]\s*\d{1,2}[일]?");
foreach (Match m in datePattern.Matches(content))
{
var cleaned = Regex.Replace(m.Value, @"[년월일\s]", "-").TrimEnd('-');
if (DateTime.TryParse(cleaned, out var dt))
{
if (dt > DateTime.Now.AddDays(365))
issues.Add($"[WARNING] 미래 날짜 감지: {m.Value}");
else if (dt < DateTime.Now.AddYears(-50))
issues.Add($"[INFO] 매우 오래된 날짜: {m.Value}");
}
}
// 4. HTML 전용 검사
if (ext is ".html" or ".htm")
{
// 빈 섹션 (h2/h3 뒤에 내용 없이 바로 다음 h2/h3)
var emptySection = Regex.Matches(content, @"<h[23][^>]*>.*?</h[23]>\s*<h[23]");
if (emptySection.Count > 0)
issues.Add($"[WARNING] 빈 섹션 {emptySection.Count}개 감지 (헤딩 뒤 내용 없음)");
// 닫히지 않은 태그
var openTags = Regex.Matches(content, @"<(table|div|section|article)\b[^/]*>").Count;
var closeTags = Regex.Matches(content, @"</(table|div|section|article)>").Count;
if (openTags != closeTags)
issues.Add($"[WARNING] HTML 태그 불균형: 열림 {openTags}개, 닫힘 {closeTags}개");
// 이미지 alt 텍스트 누락
var imgNoAlt = Regex.Matches(content, @"<img\b(?![^>]*\balt\s*=)[^>]*>");
if (imgNoAlt.Count > 0)
issues.Add($"[INFO] alt 속성 없는 이미지 {imgNoAlt.Count}개");
// 제목 태그 수
var h1Count = Regex.Matches(content, @"<h1\b").Count;
var h2Count = Regex.Matches(content, @"<h2\b").Count;
stats.Add($"구조: h1={h1Count}, h2={h2Count}개 섹션");
}
// 5. 기대 섹션 검사
if (args.TryGetProperty("expected_sections", out var sections) && sections.ValueKind == JsonValueKind.Array)
{
foreach (var sec in sections.EnumerateArray())
{
var title = sec.GetString() ?? "";
if (!string.IsNullOrEmpty(title) && !content.Contains(title, StringComparison.OrdinalIgnoreCase))
issues.Add($"[MISSING] 기대 섹션 누락: \"{title}\"");
}
}
// 6. 반복 텍스트 검사 (같은 문장 3회 이상 반복)
var sentences = Regex.Split(content, @"[.!?。]\s+")
.Where(s => s.Length > 20)
.GroupBy(s => s.Trim())
.Where(g => g.Count() >= 3);
foreach (var dup in sentences.Take(3))
issues.Add($"[WARNING] 반복 텍스트 ({dup.Count()}회): \"{dup.Key[..Math.Min(50, dup.Key.Length)]}...\"");
var suggestions = new List<string>();
if (issues.Count == 0)
suggestions.Add("문서 검증 통과 — 구조적 이슈가 발견되지 않았습니다.");
else
{
suggestions.Add($"총 {issues.Count}개 이슈 발견. 수정 후 다시 검증하세요.");
if (issues.Any(i => i.Contains("플레이스홀더")))
suggestions.Add("플레이스홀더를 실제 내용으로 교체하세요.");
if (issues.Any(i => i.Contains("빈 섹션")))
suggestions.Add("빈 섹션에 내용을 추가하거나 불필요한 헤딩을 제거하세요.");
}
return Task.FromResult(ToolResult.Ok(FormatReport(stats, issues, suggestions), fullPath));
}
private static string FormatReport(List<string> stats, List<string> issues, List<string> suggestions)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("=== 문서 검증 보고서 ===\n");
foreach (var s in stats) sb.AppendLine($"📊 {s}");
sb.AppendLine();
if (issues.Count == 0)
sb.AppendLine("✅ 이슈 없음 — 문서가 정상입니다.");
else
{
sb.AppendLine($"⚠ 발견된 이슈 ({issues.Count}건):");
foreach (var i in issues) sb.AppendLine($" {i}");
}
sb.AppendLine();
foreach (var s in suggestions) sb.AppendLine($"💡 {s}");
return sb.ToString();
}
}

View File

@@ -0,0 +1,543 @@
using System.IO;
using System.Text.Json;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Word (.docx) 문서를 생성하는 내장 스킬.
/// 테이블, 텍스트 스타일링, 머리글/바닥글, 페이지 나누기 등 고급 기능을 지원합니다.
/// </summary>
public class DocxSkill : IAgentTool
{
public string Name => "docx_create";
public string Description => "Create a rich Word (.docx) document. " +
"Supports: sections with heading+body, tables with optional header styling, " +
"text formatting (bold, italic, color, highlight, shading), " +
"headers/footers with page numbers, page breaks between sections, " +
"and numbered/bulleted lists.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "Output file path (.docx). Relative to work folder." },
["title"] = new() { Type = "string", Description = "Document title (optional)." },
["sections"] = new()
{
Type = "array",
Description = "Array of content blocks. Each block is one of:\n" +
"• Section: {\"heading\": \"...\", \"body\": \"...\", \"level\": 1|2}\n" +
"• Table: {\"type\": \"table\", \"headers\": [\"A\",\"B\"], \"rows\": [[\"1\",\"2\"]], \"style\": \"striped|plain\"}\n" +
"• PageBreak: {\"type\": \"pagebreak\"}\n" +
"• List: {\"type\": \"list\", \"style\": \"bullet|number\", \"items\": [\"item1\", \"item2\"]}\n" +
"Body text supports inline formatting: **bold**, *italic*, `code`.",
Items = new() { Type = "object" }
},
["header"] = new() { Type = "string", Description = "Header text shown at top of every page (optional)." },
["footer"] = new() { Type = "string", Description = "Footer text. Use {page} for page number. Default: 'AX Copilot · {page}' if header is set." },
["page_numbers"] = new() { Type = "boolean", Description = "Show page numbers in footer. Default: true if header or footer is set." },
},
Required = ["path", "sections"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var title = args.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null;
var footerText = args.TryGetProperty("footer", out var ftr) ? ftr.GetString() : null;
var showPageNumbers = args.TryGetProperty("page_numbers", out var pn) ? pn.GetBoolean() :
(headerText != null || footerText != null);
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
if (!fullPath.EndsWith(".docx", StringComparison.OrdinalIgnoreCase))
fullPath += ".docx";
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
try
{
var sections = args.GetProperty("sections");
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
using var doc = WordprocessingDocument.Create(fullPath, WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
mainPart.Document = new Document();
var body = mainPart.Document.AppendChild(new Body());
// 머리글/바닥글 설정
if (headerText != null || footerText != null || showPageNumbers)
AddHeaderFooter(mainPart, body, headerText, footerText, showPageNumbers);
// 제목
if (!string.IsNullOrEmpty(title))
{
body.Append(CreateTitleParagraph(title));
// 제목 아래 구분선
body.Append(new Paragraph(
new ParagraphProperties
{
ParagraphBorders = new ParagraphBorders(
new BottomBorder { Val = BorderValues.Single, Size = 6, Color = "4472C4", Space = 1 }),
SpacingBetweenLines = new SpacingBetweenLines { After = "300" },
}));
}
int sectionCount = 0;
int tableCount = 0;
foreach (var section in sections.EnumerateArray())
{
var blockType = section.TryGetProperty("type", out var bt) ? bt.GetString()?.ToLower() : null;
if (blockType == "pagebreak")
{
body.Append(CreatePageBreak());
continue;
}
if (blockType == "table")
{
body.Append(CreateTable(section));
tableCount++;
continue;
}
if (blockType == "list")
{
AppendList(body, section);
continue;
}
// 일반 섹션 (heading + body)
var heading = section.TryGetProperty("heading", out var h) ? h.GetString() ?? "" : "";
var bodyText = section.TryGetProperty("body", out var b) ? b.GetString() ?? "" : "";
var level = section.TryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
if (!string.IsNullOrEmpty(heading))
body.Append(CreateHeadingParagraph(heading, level));
if (!string.IsNullOrEmpty(bodyText))
{
foreach (var line in bodyText.Split('\n'))
{
body.Append(CreateBodyParagraph(line));
}
}
sectionCount++;
}
mainPart.Document.Save();
var parts = new List<string>();
if (!string.IsNullOrEmpty(title)) parts.Add($"제목: {title}");
if (sectionCount > 0) parts.Add($"섹션: {sectionCount}개");
if (tableCount > 0) parts.Add($"테이블: {tableCount}개");
if (headerText != null) parts.Add("머리글");
if (showPageNumbers) parts.Add("페이지번호");
return ToolResult.Ok(
$"Word 문서 생성 완료: {fullPath}\n{string.Join(", ", parts)}",
fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"Word 문서 생성 실패: {ex.Message}");
}
}
// ═══════════════════════════════════════════════════
// 제목/소제목/본문 단락 생성
// ═══════════════════════════════════════════════════
private static Paragraph CreateTitleParagraph(string text)
{
var para = new Paragraph();
para.ParagraphProperties = new ParagraphProperties
{
Justification = new Justification { Val = JustificationValues.Center },
SpacingBetweenLines = new SpacingBetweenLines { After = "100" },
};
var run = new Run(new Text(text));
run.RunProperties = new RunProperties
{
Bold = new Bold(),
FontSize = new FontSize { Val = "44" }, // 22pt
Color = new Color { Val = "1F3864" },
};
para.Append(run);
return para;
}
private static Paragraph CreateHeadingParagraph(string text, int level)
{
var para = new Paragraph();
var fontSize = level <= 1 ? "32" : "26"; // 16pt / 13pt
var color = level <= 1 ? "2E74B5" : "404040";
para.ParagraphProperties = new ParagraphProperties
{
SpacingBetweenLines = new SpacingBetweenLines { Before = level <= 1 ? "360" : "240", After = "120" },
};
// 레벨1 소제목에 하단 테두리 추가
if (level <= 1)
{
para.ParagraphProperties.ParagraphBorders = new ParagraphBorders(
new BottomBorder { Val = BorderValues.Single, Size = 4, Color = "B4C6E7", Space = 1 });
}
var run = new Run(new Text(text));
run.RunProperties = new RunProperties
{
Bold = new Bold(),
FontSize = new FontSize { Val = fontSize },
Color = new Color { Val = color },
};
para.Append(run);
return para;
}
private static Paragraph CreateBodyParagraph(string text)
{
var para = new Paragraph();
para.ParagraphProperties = new ParagraphProperties
{
SpacingBetweenLines = new SpacingBetweenLines { Line = "360" }, // 1.5배 줄간격
};
// 인라인 서식 파싱: **bold**, *italic*, `code`
AppendFormattedRuns(para, text);
return para;
}
/// <summary>**bold**, *italic*, `code` 인라인 서식을 Run으로 변환</summary>
private static void AppendFormattedRuns(Paragraph para, string text)
{
// 패턴: **bold** | *italic* | `code` | 일반텍스트
var regex = new System.Text.RegularExpressions.Regex(
@"\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`");
int lastIndex = 0;
foreach (System.Text.RegularExpressions.Match match in regex.Matches(text))
{
// 매치 전 일반 텍스트
if (match.Index > lastIndex)
para.Append(CreateRun(text[lastIndex..match.Index]));
if (match.Groups[1].Success) // **bold**
{
var run = CreateRun(match.Groups[1].Value);
run.RunProperties ??= new RunProperties();
run.RunProperties.Bold = new Bold();
para.Append(run);
}
else if (match.Groups[2].Success) // *italic*
{
var run = CreateRun(match.Groups[2].Value);
run.RunProperties ??= new RunProperties();
run.RunProperties.Italic = new Italic();
para.Append(run);
}
else if (match.Groups[3].Success) // `code`
{
var run = CreateRun(match.Groups[3].Value);
run.RunProperties ??= new RunProperties();
run.RunProperties.RunFonts = new RunFonts { Ascii = "Consolas", HighAnsi = "Consolas" };
run.RunProperties.FontSize = new FontSize { Val = "20" };
run.RunProperties.Shading = new Shading
{
Val = ShadingPatternValues.Clear,
Fill = "F2F2F2",
Color = "auto"
};
para.Append(run);
}
lastIndex = match.Index + match.Length;
}
// 나머지 텍스트
if (lastIndex < text.Length)
para.Append(CreateRun(text[lastIndex..]));
// 빈 텍스트인 경우 빈 Run 추가
if (lastIndex == 0 && text.Length == 0)
para.Append(CreateRun(""));
}
private static Run CreateRun(string text)
{
var run = new Run(new Text(text) { Space = SpaceProcessingModeValues.Preserve });
run.RunProperties = new RunProperties
{
FontSize = new FontSize { Val = "22" }, // 11pt
};
return run;
}
// ═══════════════════════════════════════════════════
// 테이블 생성
// ═══════════════════════════════════════════════════
private static Table CreateTable(JsonElement section)
{
var headers = section.TryGetProperty("headers", out var hArr) ? hArr : default;
var rows = section.TryGetProperty("rows", out var rArr) ? rArr : default;
var tableStyle = section.TryGetProperty("style", out var ts) ? ts.GetString() ?? "striped" : "striped";
var table = new Table();
// 테이블 속성 — 테두리 + 전체 너비
var tblProps = new TableProperties(
new TableBorders(
new TopBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" },
new BottomBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" },
new LeftBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" },
new RightBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" },
new InsideHorizontalBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" },
new InsideVerticalBorder { Val = BorderValues.Single, Size = 4, Color = "D9D9D9" }
),
new TableWidth { Width = "5000", Type = TableWidthUnitValues.Pct }
);
table.AppendChild(tblProps);
// 헤더 행
if (headers.ValueKind == JsonValueKind.Array)
{
var headerRow = new TableRow();
foreach (var h in headers.EnumerateArray())
{
var cell = new TableCell();
cell.TableCellProperties = new TableCellProperties
{
Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = "2E74B5", Color = "auto" },
TableCellVerticalAlignment = new TableCellVerticalAlignment { Val = TableVerticalAlignmentValues.Center },
};
var para = new Paragraph(new Run(new Text(h.GetString() ?? ""))
{
RunProperties = new RunProperties
{
Bold = new Bold(),
FontSize = new FontSize { Val = "20" },
Color = new Color { Val = "FFFFFF" },
}
});
para.ParagraphProperties = new ParagraphProperties
{
SpacingBetweenLines = new SpacingBetweenLines { Before = "40", After = "40" },
};
cell.Append(para);
headerRow.Append(cell);
}
table.Append(headerRow);
}
// 데이터 행
if (rows.ValueKind == JsonValueKind.Array)
{
int rowIdx = 0;
foreach (var row in rows.EnumerateArray())
{
var dataRow = new TableRow();
foreach (var cellVal in row.EnumerateArray())
{
var cell = new TableCell();
// striped 스타일: 짝수행에 배경색
if (tableStyle == "striped" && rowIdx % 2 == 0)
{
cell.TableCellProperties = new TableCellProperties
{
Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = "F2F7FB", Color = "auto" },
};
}
var para = new Paragraph(new Run(new Text(cellVal.ToString()) { Space = SpaceProcessingModeValues.Preserve })
{
RunProperties = new RunProperties { FontSize = new FontSize { Val = "20" } }
});
para.ParagraphProperties = new ParagraphProperties
{
SpacingBetweenLines = new SpacingBetweenLines { Before = "20", After = "20" },
};
cell.Append(para);
dataRow.Append(cell);
}
table.Append(dataRow);
rowIdx++;
}
}
return table;
}
// ═══════════════════════════════════════════════════
// 리스트 (번호/불릿)
// ═══════════════════════════════════════════════════
private static void AppendList(Body body, JsonElement section)
{
var items = section.TryGetProperty("items", out var arr) ? arr : default;
var listStyle = section.TryGetProperty("style", out var ls) ? ls.GetString() ?? "bullet" : "bullet";
if (items.ValueKind != JsonValueKind.Array) return;
int idx = 1;
foreach (var item in items.EnumerateArray())
{
var text = item.GetString() ?? item.ToString();
var prefix = listStyle == "number" ? $"{idx}. " : "• ";
var para = new Paragraph();
para.ParagraphProperties = new ParagraphProperties
{
Indentation = new Indentation { Left = "720" }, // 0.5 inch
SpacingBetweenLines = new SpacingBetweenLines { Line = "320" },
};
var prefixRun = new Run(new Text(prefix) { Space = SpaceProcessingModeValues.Preserve });
prefixRun.RunProperties = new RunProperties
{
FontSize = new FontSize { Val = "22" },
Bold = listStyle == "number" ? new Bold() : null,
};
para.Append(prefixRun);
var textRun = new Run(new Text(text) { Space = SpaceProcessingModeValues.Preserve });
textRun.RunProperties = new RunProperties { FontSize = new FontSize { Val = "22" } };
para.Append(textRun);
body.Append(para);
idx++;
}
}
// ═══════════════════════════════════════════════════
// 페이지 나누기
// ═══════════════════════════════════════════════════
private static Paragraph CreatePageBreak()
{
var para = new Paragraph();
var run = new Run(new Break { Type = BreakValues.Page });
para.Append(run);
return para;
}
// ═══════════════════════════════════════════════════
// 머리글/바닥글
// ═══════════════════════════════════════════════════
private static void AddHeaderFooter(MainDocumentPart mainPart, Body body,
string? headerText, string? footerText, bool showPageNumbers)
{
// 머리글
if (!string.IsNullOrEmpty(headerText))
{
var headerPart = mainPart.AddNewPart<HeaderPart>();
var header = new Header();
var para = new Paragraph(new Run(new Text(headerText))
{
RunProperties = new RunProperties
{
FontSize = new FontSize { Val = "18" }, // 9pt
Color = new Color { Val = "808080" },
}
});
para.ParagraphProperties = new ParagraphProperties
{
Justification = new Justification { Val = JustificationValues.Right },
};
header.Append(para);
headerPart.Header = header;
// SectionProperties에 머리글 연결
var secProps = body.GetFirstChild<SectionProperties>() ?? body.AppendChild(new SectionProperties());
secProps.Append(new HeaderReference
{
Type = HeaderFooterValues.Default,
Id = mainPart.GetIdOfPart(headerPart)
});
}
// 바닥글
if (!string.IsNullOrEmpty(footerText) || showPageNumbers)
{
var footerPart = mainPart.AddNewPart<FooterPart>();
var footer = new Footer();
var para = new Paragraph();
para.ParagraphProperties = new ParagraphProperties
{
Justification = new Justification { Val = JustificationValues.Center },
};
var displayText = footerText ?? "AX Copilot";
if (showPageNumbers)
{
// 바닥글 텍스트 + 페이지 번호
if (displayText.Contains("{page}"))
{
var parts = displayText.Split("{page}");
para.Append(CreateFooterRun(parts[0]));
para.Append(CreatePageNumberRun());
if (parts.Length > 1)
para.Append(CreateFooterRun(parts[1]));
}
else
{
para.Append(CreateFooterRun(displayText + " · "));
para.Append(CreatePageNumberRun());
}
}
else
{
para.Append(CreateFooterRun(displayText));
}
footer.Append(para);
footerPart.Footer = footer;
var secProps = body.GetFirstChild<SectionProperties>() ?? body.AppendChild(new SectionProperties());
secProps.Append(new FooterReference
{
Type = HeaderFooterValues.Default,
Id = mainPart.GetIdOfPart(footerPart)
});
}
}
private static Run CreateFooterRun(string text) =>
new(new Text(text) { Space = SpaceProcessingModeValues.Preserve })
{
RunProperties = new RunProperties
{
FontSize = new FontSize { Val = "16" },
Color = new Color { Val = "999999" },
}
};
private static Run CreatePageNumberRun()
{
var run = new Run();
run.RunProperties = new RunProperties
{
FontSize = new FontSize { Val = "16" },
Color = new Color { Val = "999999" },
};
run.Append(new FieldChar { FieldCharType = FieldCharValues.Begin });
run.Append(new FieldCode(" PAGE ") { Space = SpaceProcessingModeValues.Preserve });
run.Append(new FieldChar { FieldCharType = FieldCharValues.End });
return run;
}
}

View File

@@ -0,0 +1,189 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>파일 인코딩 감지 및 변환 도구.</summary>
public class EncodingTool : IAgentTool
{
public string Name => "encoding_tool";
public string Description =>
"Detect and convert file text encoding. Actions: " +
"'detect' — detect file encoding (UTF-8, EUC-KR, etc.); " +
"'convert' — convert file from one encoding to another; " +
"'list' — list common encoding names.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "Action: detect, convert, list",
Enum = ["detect", "convert", "list"],
},
["path"] = new()
{
Type = "string",
Description = "File path",
},
["from_encoding"] = new()
{
Type = "string",
Description = "Source encoding name (e.g. 'euc-kr', 'shift-jis'). Auto-detected if omitted.",
},
["to_encoding"] = new()
{
Type = "string",
Description = "Target encoding name (default: 'utf-8')",
},
},
Required = ["action"],
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
if (action == "list")
return ListEncodings();
var rawPath = args.TryGetProperty("path", out var pv) ? pv.GetString() ?? "" : "";
if (string.IsNullOrEmpty(rawPath))
return ToolResult.Fail("'path'가 필요합니다.");
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
if (!context.IsPathAllowed(path))
return ToolResult.Fail($"경로 접근 차단: {path}");
if (!File.Exists(path))
return ToolResult.Fail($"파일 없음: {path}");
try
{
return action switch
{
"detect" => DetectEncoding(path),
"convert" => await ConvertEncoding(path, args, context),
_ => ToolResult.Fail($"Unknown action: {action}"),
};
}
catch (Exception ex)
{
return ToolResult.Fail($"인코딩 처리 오류: {ex.Message}");
}
}
private static ToolResult DetectEncoding(string path)
{
var bytes = File.ReadAllBytes(path);
var detected = DetectEncodingFromBytes(bytes);
var sb = new StringBuilder();
sb.AppendLine($"File: {Path.GetFileName(path)}");
sb.AppendLine($"Size: {bytes.Length:N0} bytes");
sb.AppendLine($"Detected Encoding: {detected.EncodingName}");
sb.AppendLine($"Code Page: {detected.CodePage}");
sb.AppendLine($"BOM Present: {HasBom(bytes)}");
return ToolResult.Ok(sb.ToString());
}
private static async Task<ToolResult> ConvertEncoding(string path, JsonElement args, AgentContext context)
{
var toName = args.TryGetProperty("to_encoding", out var te) ? te.GetString() ?? "utf-8" : "utf-8";
// 쓰기 권한 확인
var allowed = await context.CheckWritePermissionAsync("encoding_tool", path);
if (!allowed) return ToolResult.Fail("파일 쓰기 권한이 거부되었습니다.");
// 소스 인코딩 결정
Encoding fromEnc;
if (args.TryGetProperty("from_encoding", out var fe) && !string.IsNullOrEmpty(fe.GetString()))
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
fromEnc = Encoding.GetEncoding(fe.GetString()!);
}
else
{
var rawBytes = File.ReadAllBytes(path);
fromEnc = DetectEncodingFromBytes(rawBytes);
}
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var toEnc = Encoding.GetEncoding(toName);
var content = File.ReadAllText(path, fromEnc);
File.WriteAllText(path, content, toEnc);
return ToolResult.Ok(
$"변환 완료: {fromEnc.EncodingName} → {toEnc.EncodingName}\nFile: {path}",
filePath: path);
}
private static ToolResult ListEncodings()
{
var sb = new StringBuilder();
sb.AppendLine("주요 인코딩 목록:");
sb.AppendLine(" utf-8 — UTF-8 (유니코드, 기본)");
sb.AppendLine(" utf-16 — UTF-16 LE");
sb.AppendLine(" utf-16BE — UTF-16 BE");
sb.AppendLine(" euc-kr — EUC-KR (한국어)");
sb.AppendLine(" ks_c_5601-1987 — 한글 완성형");
sb.AppendLine(" shift_jis — Shift-JIS (일본어)");
sb.AppendLine(" gb2312 — GB2312 (중국어 간체)");
sb.AppendLine(" iso-8859-1 — Latin-1 (서유럽)");
sb.AppendLine(" ascii — US-ASCII");
sb.AppendLine(" utf-32 — UTF-32");
return ToolResult.Ok(sb.ToString());
}
private static Encoding DetectEncodingFromBytes(byte[] bytes)
{
// BOM 감지
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
return Encoding.UTF8;
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE)
return Encoding.Unicode; // UTF-16 LE
if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF)
return Encoding.BigEndianUnicode;
// 간단한 UTF-8 유효성 검사
if (IsValidUtf8(bytes))
return Encoding.UTF8;
// 한국어 환경 기본값
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
try { return Encoding.GetEncoding("euc-kr"); }
catch { return Encoding.Default; }
}
private static bool IsValidUtf8(byte[] bytes)
{
var i = 0;
var hasMultibyte = false;
while (i < bytes.Length)
{
if (bytes[i] <= 0x7F) { i++; continue; }
int extra;
if ((bytes[i] & 0xE0) == 0xC0) extra = 1;
else if ((bytes[i] & 0xF0) == 0xE0) extra = 2;
else if ((bytes[i] & 0xF8) == 0xF0) extra = 3;
else return false;
if (i + extra >= bytes.Length) return false;
for (var j = 1; j <= extra; j++)
if ((bytes[i + j] & 0xC0) != 0x80) return false;
hasMultibyte = true;
i += extra + 1;
}
return hasMultibyte || bytes.Length < 100; // 순수 ASCII도 UTF-8으로 간주
}
private static bool HasBom(byte[] bytes)
{
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) return true;
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) return true;
if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) return true;
return false;
}
}

View File

@@ -0,0 +1,127 @@
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 환경변수 읽기·쓰기 도구.
/// 현재 프로세스 범위에서만 동작하며 시스템 환경변수는 변경하지 않습니다.
/// </summary>
public class EnvTool : IAgentTool
{
public string Name => "env_tool";
public string Description =>
"Read or set environment variables (process scope only). Actions: " +
"'get' — read an environment variable value; " +
"'set' — set an environment variable (process scope, not permanent); " +
"'list' — list all environment variables; " +
"'expand' — expand %VAR% references in a string.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "Action to perform",
Enum = ["get", "set", "list", "expand"],
},
["name"] = new()
{
Type = "string",
Description = "Variable name (for get/set)",
},
["value"] = new()
{
Type = "string",
Description = "Variable value (for set) or string to expand (for expand)",
},
["filter"] = new()
{
Type = "string",
Description = "Filter pattern for list action (case-insensitive substring match)",
},
},
Required = ["action"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
try
{
return Task.FromResult(action switch
{
"get" => Get(args),
"set" => Set(args),
"list" => ListVars(args),
"expand" => Expand(args),
_ => ToolResult.Fail($"Unknown action: {action}"),
});
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"환경변수 오류: {ex.Message}"));
}
}
private static ToolResult Get(JsonElement args)
{
if (!args.TryGetProperty("name", out var n))
return ToolResult.Fail("'name' parameter is required for get action");
var name = n.GetString() ?? "";
var value = Environment.GetEnvironmentVariable(name);
return value != null
? ToolResult.Ok($"{name}={value}")
: ToolResult.Ok($"{name} is not set");
}
private static ToolResult Set(JsonElement args)
{
if (!args.TryGetProperty("name", out var n))
return ToolResult.Fail("'name' parameter is required for set action");
if (!args.TryGetProperty("value", out var v))
return ToolResult.Fail("'value' parameter is required for set action");
var name = n.GetString() ?? "";
var value = v.GetString() ?? "";
Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.Process);
return ToolResult.Ok($"✓ Set {name}={value} (process scope)");
}
private static ToolResult ListVars(JsonElement args)
{
var filter = args.TryGetProperty("filter", out var f) ? f.GetString() ?? "" : "";
var vars = Environment.GetEnvironmentVariables();
var entries = new List<string>();
foreach (System.Collections.DictionaryEntry entry in vars)
{
var key = entry.Key?.ToString() ?? "";
var val = entry.Value?.ToString() ?? "";
if (!string.IsNullOrEmpty(filter) &&
!key.Contains(filter, StringComparison.OrdinalIgnoreCase) &&
!val.Contains(filter, StringComparison.OrdinalIgnoreCase))
continue;
// 긴 값은 자르기
if (val.Length > 120) val = val[..120] + "...";
entries.Add($"{key}={val}");
}
entries.Sort(StringComparer.OrdinalIgnoreCase);
var result = $"Environment variables ({entries.Count}):\n" + string.Join("\n", entries);
if (result.Length > 8000) result = result[..8000] + "\n... (truncated)";
return ToolResult.Ok(result);
}
private static ToolResult Expand(JsonElement args)
{
if (!args.TryGetProperty("value", out var v))
return ToolResult.Fail("'value' parameter is required for expand action");
var input = v.GetString() ?? "";
var expanded = Environment.ExpandEnvironmentVariables(input);
return ToolResult.Ok(expanded);
}
}

View File

@@ -0,0 +1,406 @@
using System.IO;
using System.Text.Json;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Excel (.xlsx) 문서를 생성하는 내장 스킬.
/// 셀 서식(색상/테두리/볼드), 수식, 열 너비, 셀 병합, 틀 고정 등을 지원합니다.
/// </summary>
public class ExcelSkill : IAgentTool
{
public string Name => "excel_create";
public string Description => "Create a styled Excel (.xlsx) file. " +
"Supports: header styling (bold white text on blue background), " +
"striped rows, column auto-width, formulas (=SUM, =AVERAGE, etc), " +
"cell merge, freeze panes (freeze header row), and number formatting.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "Output file path (.xlsx). Relative to work folder." },
["sheet_name"] = new() { Type = "string", Description = "Sheet name. Default: 'Sheet1'." },
["headers"] = new() { Type = "array", Description = "Column headers as JSON array of strings.", Items = new() { Type = "string" } },
["rows"] = new() { Type = "array", Description = "Data rows as JSON array of arrays. Use string starting with '=' for formulas (e.g. '=SUM(A2:A10)').", Items = new() { Type = "array", Items = new() { Type = "string" } } },
["style"] = new() { Type = "string", Description = "Table style: 'styled' (blue header, striped rows, borders) or 'plain'. Default: 'styled'" },
["col_widths"] = new() { Type = "array", Description = "Column widths as JSON array of numbers (in characters). e.g. [15, 10, 20]. Auto-fit if omitted.", Items = new() { Type = "number" } },
["freeze_header"] = new() { Type = "boolean", Description = "Freeze the header row. Default: true for styled." },
["merges"] = new() { Type = "array", Description = "Cell merge ranges. e.g. [\"A1:C1\", \"D5:D8\"]", Items = new() { Type = "string" } },
["summary_row"] = new() { Type = "object", Description = "Auto-generate summary row. {\"label\": \"합계\", \"columns\": {\"B\": \"SUM\", \"C\": \"AVERAGE\"}}. Adds formulas at bottom." },
},
Required = ["path", "headers", "rows"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var sheetName = args.TryGetProperty("sheet_name", out var sn) ? sn.GetString() ?? "Sheet1" : "Sheet1";
var tableStyle = args.TryGetProperty("style", out var st) ? st.GetString() ?? "styled" : "styled";
var isStyled = tableStyle != "plain";
var freezeHeader = args.TryGetProperty("freeze_header", out var fh) ? fh.GetBoolean() : isStyled;
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
if (!fullPath.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase))
fullPath += ".xlsx";
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
try
{
var headers = args.GetProperty("headers");
var rows = args.GetProperty("rows");
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
using var spreadsheet = SpreadsheetDocument.Create(fullPath, SpreadsheetDocumentType.Workbook);
var workbookPart = spreadsheet.AddWorkbookPart();
workbookPart.Workbook = new Workbook();
// Stylesheet 추가 (서식용)
var stylesPart = workbookPart.AddNewPart<WorkbookStylesPart>();
stylesPart.Stylesheet = CreateStylesheet(isStyled);
stylesPart.Stylesheet.Save();
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
worksheetPart.Worksheet = new Worksheet();
// 열 너비 설정
var colCount = headers.GetArrayLength();
var columns = CreateColumns(args, colCount);
if (columns != null)
worksheetPart.Worksheet.Append(columns);
var sheetData = new SheetData();
worksheetPart.Worksheet.Append(sheetData);
var sheets = workbookPart.Workbook.AppendChild(new Sheets());
sheets.Append(new Sheet
{
Id = workbookPart.GetIdOfPart(worksheetPart),
SheetId = 1,
Name = sheetName,
});
// 헤더 행 (styleIndex 1 = 볼드 흰색 + 파란배경)
var headerRow = new Row { RowIndex = 1 };
int colIdx = 0;
foreach (var h in headers.EnumerateArray())
{
var cellRef = GetCellReference(colIdx, 0);
var cell = new Cell
{
CellReference = cellRef,
DataType = CellValues.String,
CellValue = new CellValue(h.GetString() ?? ""),
StyleIndex = isStyled ? (uint)1 : 0,
};
headerRow.Append(cell);
colIdx++;
}
sheetData.Append(headerRow);
// 데이터 행
int rowCount = 0;
uint rowNum = 2;
foreach (var row in rows.EnumerateArray())
{
var dataRow = new Row { RowIndex = rowNum };
int ci = 0;
foreach (var cellVal in row.EnumerateArray())
{
var cellRef = GetCellReference(ci, (int)rowNum - 1);
var cell = new Cell { CellReference = cellRef };
// striped 스타일: 짝수행
if (isStyled && rowCount % 2 == 0)
cell.StyleIndex = 2; // 연한 파란 배경
var strVal = cellVal.ToString();
// 수식 (=으로 시작)
if (strVal.StartsWith('='))
{
cell.CellFormula = new CellFormula(strVal);
cell.DataType = null; // 수식은 DataType 없음
}
else if (cellVal.ValueKind == JsonValueKind.Number)
{
cell.DataType = CellValues.Number;
cell.CellValue = new CellValue(cellVal.GetDouble().ToString());
}
else
{
cell.DataType = CellValues.String;
cell.CellValue = new CellValue(strVal);
}
dataRow.Append(cell);
ci++;
}
sheetData.Append(dataRow);
rowCount++;
rowNum++;
}
// 요약 행 (summary_row)
if (args.TryGetProperty("summary_row", out var summary))
AddSummaryRow(sheetData, summary, rowNum, colCount, rowCount, isStyled);
// 셀 병합
if (args.TryGetProperty("merges", out var merges) && merges.ValueKind == JsonValueKind.Array)
{
var mergeCells = new MergeCells();
foreach (var merge in merges.EnumerateArray())
{
var range = merge.GetString();
if (!string.IsNullOrEmpty(range))
mergeCells.Append(new MergeCell { Reference = range });
}
if (mergeCells.HasChildren)
worksheetPart.Worksheet.InsertAfter(mergeCells, sheetData);
}
// 틀 고정 (헤더 행)
if (freezeHeader)
{
var sheetViews = new SheetViews(new SheetView(
new Pane
{
VerticalSplit = 1,
TopLeftCell = "A2",
ActivePane = PaneValues.BottomLeft,
State = PaneStateValues.Frozen
},
new Selection
{
Pane = PaneValues.BottomLeft,
ActiveCell = "A2",
SequenceOfReferences = new ListValue<StringValue> { InnerText = "A2" }
})
{ TabSelected = true, WorkbookViewId = 0 });
var insertBefore = (OpenXmlElement?)worksheetPart.Worksheet.GetFirstChild<Columns>()
?? worksheetPart.Worksheet.GetFirstChild<SheetData>();
worksheetPart.Worksheet.InsertBefore(sheetViews, insertBefore);
}
workbookPart.Workbook.Save();
var features = new List<string>();
if (isStyled) features.Add("스타일 적용");
if (freezeHeader) features.Add("틀 고정");
if (args.TryGetProperty("merges", out _)) features.Add("셀 병합");
if (args.TryGetProperty("summary_row", out _)) features.Add("요약행");
var featureStr = features.Count > 0 ? $" [{string.Join(", ", features)}]" : "";
return ToolResult.Ok(
$"Excel 파일 생성 완료: {fullPath}\n시트: {sheetName}, 열: {colCount}, 행: {rowCount}{featureStr}",
fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"Excel 생성 실패: {ex.Message}");
}
}
// ═══════════════════════════════════════════════════
// Stylesheet (셀 서식)
// ═══════════════════════════════════════════════════
private static Stylesheet CreateStylesheet(bool isStyled)
{
var stylesheet = new Stylesheet();
// Fonts
var fonts = new Fonts(
new Font( // 0: 기본
new FontSize { Val = 11 },
new FontName { Val = "맑은 고딕" }
),
new Font( // 1: 볼드 흰색 (헤더용)
new Bold(),
new FontSize { Val = 11 },
new Color { Rgb = "FFFFFFFF" },
new FontName { Val = "맑은 고딕" }
),
new Font( // 2: 볼드 (요약행용)
new Bold(),
new FontSize { Val = 11 },
new FontName { Val = "맑은 고딕" }
)
);
stylesheet.Append(fonts);
// Fills
var fills = new Fills(
new Fill(new PatternFill { PatternType = PatternValues.None }), // 0: none (필수)
new Fill(new PatternFill { PatternType = PatternValues.Gray125 }), // 1: gray125 (필수)
new Fill(new PatternFill // 2: 파란 헤더 배경
{
PatternType = PatternValues.Solid,
ForegroundColor = new ForegroundColor { Rgb = "FF2E74B5" },
BackgroundColor = new BackgroundColor { Indexed = 64 }
}),
new Fill(new PatternFill // 3: 연한 파란 (striped)
{
PatternType = PatternValues.Solid,
ForegroundColor = new ForegroundColor { Rgb = "FFF2F7FB" },
BackgroundColor = new BackgroundColor { Indexed = 64 }
}),
new Fill(new PatternFill // 4: 연한 회색 (요약행)
{
PatternType = PatternValues.Solid,
ForegroundColor = new ForegroundColor { Rgb = "FFE8E8E8" },
BackgroundColor = new BackgroundColor { Indexed = 64 }
})
);
stylesheet.Append(fills);
// Borders
var borders = new Borders(
new Border( // 0: 테두리 없음
new LeftBorder(), new RightBorder(),
new TopBorder(), new BottomBorder(), new DiagonalBorder()
),
new Border( // 1: 얇은 테두리
new LeftBorder(new Color { Rgb = "FFD9D9D9" }) { Style = BorderStyleValues.Thin },
new RightBorder(new Color { Rgb = "FFD9D9D9" }) { Style = BorderStyleValues.Thin },
new TopBorder(new Color { Rgb = "FFD9D9D9" }) { Style = BorderStyleValues.Thin },
new BottomBorder(new Color { Rgb = "FFD9D9D9" }) { Style = BorderStyleValues.Thin },
new DiagonalBorder()
)
);
stylesheet.Append(borders);
// CellFormats
var cellFormats = new CellFormats(
new CellFormat // 0: 기본
{
FontId = 0, FillId = 0, BorderId = isStyled ? (uint)1 : 0, ApplyBorder = isStyled
},
new CellFormat // 1: 헤더 (볼드 흰색 + 파란배경 + 테두리)
{
FontId = 1, FillId = 2, BorderId = 1,
ApplyFont = true, ApplyFill = true, ApplyBorder = true,
Alignment = new Alignment { Horizontal = HorizontalAlignmentValues.Center, Vertical = VerticalAlignmentValues.Center }
},
new CellFormat // 2: striped 행 (연한 파란 배경)
{
FontId = 0, FillId = 3, BorderId = 1,
ApplyFill = true, ApplyBorder = true
},
new CellFormat // 3: 요약행 (볼드 + 회색 배경)
{
FontId = 2, FillId = 4, BorderId = 1,
ApplyFont = true, ApplyFill = true, ApplyBorder = true
}
);
stylesheet.Append(cellFormats);
return stylesheet;
}
// ═══════════════════════════════════════════════════
// 열 너비
// ═══════════════════════════════════════════════════
private static Columns? CreateColumns(JsonElement args, int colCount)
{
var hasWidths = args.TryGetProperty("col_widths", out var widthsArr) && widthsArr.ValueKind == JsonValueKind.Array;
// col_widths가 없으면 기본 너비 15 적용
var columns = new Columns();
for (int i = 0; i < colCount; i++)
{
double width = 15; // 기본 너비
if (hasWidths && i < widthsArr.GetArrayLength())
width = widthsArr[i].GetDouble();
columns.Append(new Column
{
Min = (uint)(i + 1),
Max = (uint)(i + 1),
Width = width,
CustomWidth = true,
});
}
return columns;
}
// ═══════════════════════════════════════════════════
// 요약 행
// ═══════════════════════════════════════════════════
private static void AddSummaryRow(SheetData sheetData, JsonElement summary,
uint rowNum, int colCount, int dataRowCount, bool isStyled)
{
var label = summary.TryGetProperty("label", out var lbl) ? lbl.GetString() ?? "합계" : "합계";
var colFormulas = summary.TryGetProperty("columns", out var cols) ? cols : default;
var summaryRow = new Row { RowIndex = rowNum };
// 첫 번째 열에 라벨
var labelCell = new Cell
{
CellReference = GetCellReference(0, (int)rowNum - 1),
DataType = CellValues.String,
CellValue = new CellValue(label),
StyleIndex = isStyled ? (uint)3 : 0,
};
summaryRow.Append(labelCell);
// 나머지 열에 수식 또는 빈 셀
for (int ci = 1; ci < colCount; ci++)
{
var colLetter = GetColumnLetter(ci);
var cell = new Cell
{
CellReference = GetCellReference(ci, (int)rowNum - 1),
StyleIndex = isStyled ? (uint)3 : 0,
};
if (colFormulas.ValueKind == JsonValueKind.Object &&
colFormulas.TryGetProperty(colLetter, out var funcName))
{
var func = funcName.GetString()?.ToUpper() ?? "SUM";
var startRow = 2;
var endRow = startRow + dataRowCount - 1;
cell.CellFormula = new CellFormula($"={func}({colLetter}{startRow}:{colLetter}{endRow})");
}
summaryRow.Append(cell);
}
sheetData.Append(summaryRow);
}
// ═══════════════════════════════════════════════════
// 유틸리티
// ═══════════════════════════════════════════════════
private static string GetColumnLetter(int colIndex)
{
var result = "";
while (colIndex >= 0)
{
result = (char)('A' + colIndex % 26) + result;
colIndex = colIndex / 26 - 1;
}
return result;
}
private static string GetCellReference(int colIndex, int rowIndex)
=> $"{GetColumnLetter(colIndex)}{rowIndex + 1}";
}

View File

@@ -0,0 +1,127 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>파일의 특정 부분을 수정하는 도구 (old_string → new_string 패턴).</summary>
public class FileEditTool : IAgentTool
{
public string Name => "file_edit";
public string Description => "Edit a file by replacing an exact string match. Set replace_all=true to replace all occurrences; otherwise old_string must be unique.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "File path to edit" },
["old_string"] = new() { Type = "string", Description = "Exact string to find and replace" },
["new_string"] = new() { Type = "string", Description = "Replacement string" },
["replace_all"] = new() { Type = "boolean", Description = "Replace all occurrences (default false). If false, old_string must be unique." },
},
Required = ["path", "old_string", "new_string"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var oldStr = args.GetProperty("old_string").GetString() ?? "";
var newStr = args.GetProperty("new_string").GetString() ?? "";
var replaceAll = args.TryGetProperty("replace_all", out var ra) && ra.GetBoolean();
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
if (!File.Exists(fullPath))
return ToolResult.Fail($"파일이 존재하지 않습니다: {fullPath}");
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
try
{
var read = await TextFileCodec.ReadAllTextAsync(fullPath, ct);
var content = read.Text;
var count = CountOccurrences(content, oldStr);
if (count == 0)
return ToolResult.Fail($"old_string을 파일에서 찾을 수 없습니다.");
if (!replaceAll && count > 1)
return ToolResult.Fail($"old_string이 {count}번 발견됩니다. replace_all=true로 전체 교체하거나, 고유한 문자열을 지정하세요.");
// Diff Preview: 변경 내용을 컨텍스트와 함께 표시
var diffPreview = GenerateDiff(content, oldStr, newStr, fullPath);
var updated = content.Replace(oldStr, newStr);
var writeEncoding = TextFileCodec.ResolveWriteEncoding(read.Encoding, read.HasBom);
await TextFileCodec.WriteAllTextAsync(fullPath, updated, writeEncoding, ct);
var msg = replaceAll && count > 1
? $"파일 수정 완료: {fullPath} ({count}곳 전체 교체)"
: $"파일 수정 완료: {fullPath}";
return ToolResult.Ok($"{msg}\n\n{diffPreview}", fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"파일 수정 실패: {ex.Message}");
}
}
/// <summary>변경 전/후 diff를 생성합니다 (unified diff 스타일).</summary>
private static string GenerateDiff(string content, string oldStr, string newStr, string filePath)
{
var lines = content.Split('\n');
var matchIdx = content.IndexOf(oldStr, StringComparison.Ordinal);
if (matchIdx < 0) return "";
// 변경 시작 줄 번호 계산
var startLine = content[..matchIdx].Count(c => c == '\n');
var oldLines = oldStr.Split('\n');
var newLines = newStr.Split('\n');
var sb = new StringBuilder();
var fileName = Path.GetFileName(filePath);
sb.AppendLine($"--- {fileName} (before)");
sb.AppendLine($"+++ {fileName} (after)");
// 컨텍스트 라인 수
const int ctx = 2;
var ctxStart = Math.Max(0, startLine - ctx);
var ctxEnd = Math.Min(lines.Length - 1, startLine + oldLines.Length - 1 + ctx);
sb.AppendLine($"@@ -{ctxStart + 1},{ctxEnd - ctxStart + 1} @@");
// 앞쪽 컨텍스트
for (int i = ctxStart; i < startLine; i++)
sb.AppendLine($" {lines[i].TrimEnd('\r')}");
// 삭제 라인
foreach (var line in oldLines)
sb.AppendLine($"-{line.TrimEnd('\r')}");
// 추가 라인
foreach (var line in newLines)
sb.AppendLine($"+{line.TrimEnd('\r')}");
// 뒤쪽 컨텍스트
var afterEnd = startLine + oldLines.Length;
for (int i = afterEnd; i <= ctxEnd && i < lines.Length; i++)
sb.AppendLine($" {lines[i].TrimEnd('\r')}");
return sb.ToString().TrimEnd();
}
private static int CountOccurrences(string text, string search)
{
if (string.IsNullOrEmpty(search)) return 0;
int count = 0, idx = 0;
while ((idx = text.IndexOf(search, idx, StringComparison.Ordinal)) != -1)
{
count++;
idx += search.Length;
}
return count;
}
}

View File

@@ -0,0 +1,98 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>파일/폴더 메타 정보(크기, 수정일, 줄 수 등) 조회 도구.</summary>
public class FileInfoTool : IAgentTool
{
public string Name => "file_info";
public string Description =>
"Get file or directory metadata without reading contents. " +
"Returns: size, created/modified dates, line count (files), item count (directories), encoding hint.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new()
{
Type = "string",
Description = "File or directory path",
},
},
Required = ["path"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var rawPath = args.GetProperty("path").GetString() ?? "";
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
if (!context.IsPathAllowed(path))
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {path}"));
try
{
if (File.Exists(path))
{
var fi = new FileInfo(path);
var sb = new StringBuilder();
sb.AppendLine($"Type: File");
sb.AppendLine($"Path: {fi.FullName}");
sb.AppendLine($"Size: {FormatSize(fi.Length)} ({fi.Length:N0} bytes)");
sb.AppendLine($"Extension: {fi.Extension}");
sb.AppendLine($"Created: {fi.CreationTime:yyyy-MM-dd HH:mm:ss}");
sb.AppendLine($"Modified: {fi.LastWriteTime:yyyy-MM-dd HH:mm:ss}");
sb.AppendLine($"ReadOnly: {fi.IsReadOnly}");
// 텍스트 파일이면 줄 수 카운트 (최대 100만 줄까지)
var textExts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ ".cs", ".py", ".js", ".ts", ".java", ".cpp", ".c", ".h", ".xml", ".json",
".yaml", ".yml", ".md", ".txt", ".csv", ".html", ".htm", ".css", ".sql",
".sh", ".bat", ".ps1", ".config", ".ini", ".log", ".xaml" };
if (textExts.Contains(fi.Extension) && fi.Length < 50 * 1024 * 1024)
{
var lineCount = File.ReadLines(path).Take(1_000_000).Count();
sb.AppendLine($"Lines: {lineCount:N0}{(lineCount >= 1_000_000 ? "+" : "")}");
}
return Task.FromResult(ToolResult.Ok(sb.ToString()));
}
if (Directory.Exists(path))
{
var di = new DirectoryInfo(path);
var files = di.GetFiles("*", SearchOption.TopDirectoryOnly);
var dirs = di.GetDirectories();
var totalSize = files.Sum(f => f.Length);
var sb = new StringBuilder();
sb.AppendLine($"Type: Directory");
sb.AppendLine($"Path: {di.FullName}");
sb.AppendLine($"Files: {files.Length}");
sb.AppendLine($"Subdirectories: {dirs.Length}");
sb.AppendLine($"Total Size (top-level files): {FormatSize(totalSize)}");
sb.AppendLine($"Created: {di.CreationTime:yyyy-MM-dd HH:mm:ss}");
sb.AppendLine($"Modified: {di.LastWriteTime:yyyy-MM-dd HH:mm:ss}");
return Task.FromResult(ToolResult.Ok(sb.ToString()));
}
return Task.FromResult(ToolResult.Fail($"경로를 찾을 수 없습니다: {path}"));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"정보 조회 오류: {ex.Message}"));
}
}
private static string FormatSize(long bytes) => bytes switch
{
< 1024 => $"{bytes}B",
< 1024 * 1024 => $"{bytes / 1024.0:F1}KB",
< 1024L * 1024 * 1024 => $"{bytes / (1024.0 * 1024):F1}MB",
_ => $"{bytes / (1024.0 * 1024 * 1024):F2}GB",
};
}

View File

@@ -0,0 +1,137 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>파일/폴더 이동·복사·이름변경·삭제·디렉토리 생성 도구.</summary>
public class FileManageTool : IAgentTool
{
public string Name => "file_manage";
public string Description =>
"Manage files and directories. Actions: " +
"'move' — move file/folder to destination; " +
"'copy' — copy file/folder to destination; " +
"'rename' — rename file/folder; " +
"'delete' — delete file (requires Ask permission); " +
"'mkdir' — create directory recursively.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "Action to perform",
Enum = ["move", "copy", "rename", "delete", "mkdir"],
},
["path"] = new()
{
Type = "string",
Description = "Source file/folder path",
},
["destination"] = new()
{
Type = "string",
Description = "Destination path (for move/copy/rename)",
},
},
Required = ["action", "path"],
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var rawPath = args.GetProperty("path").GetString() ?? "";
var dest = args.TryGetProperty("destination", out var d) ? d.GetString() ?? "" : "";
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
if (!context.IsPathAllowed(path))
return ToolResult.Fail($"경로 접근 차단: {path}");
try
{
switch (action)
{
case "mkdir":
Directory.CreateDirectory(path);
return ToolResult.Ok($"디렉토리 생성: {path}");
case "delete":
if (context.AskPermission != null)
{
var allowed = await context.AskPermission("file_manage(delete)", path);
if (!allowed) return ToolResult.Fail("사용자가 삭제를 거부했습니다.");
}
if (File.Exists(path))
{
File.Delete(path);
return ToolResult.Ok($"파일 삭제: {path}", filePath: path);
}
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
return ToolResult.Ok($"폴더 삭제: {path}", filePath: path);
}
return ToolResult.Fail($"경로를 찾을 수 없습니다: {path}");
case "move":
case "copy":
case "rename":
if (string.IsNullOrEmpty(dest))
return ToolResult.Fail($"'{action}' 작업에는 'destination'이 필요합니다.");
var destPath = Path.IsPathRooted(dest) ? dest : Path.Combine(context.WorkFolder, dest);
if (!context.IsPathAllowed(destPath))
return ToolResult.Fail($"대상 경로 접근 차단: {destPath}");
// 대상 디렉토리 자동 생성
var destDir = Path.GetDirectoryName(destPath);
if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir))
Directory.CreateDirectory(destDir);
if (action == "rename")
{
// rename: 같은 폴더 내 이름 변경
var dir = Path.GetDirectoryName(path) ?? context.WorkFolder;
destPath = Path.Combine(dir, dest);
}
if (File.Exists(path))
{
if (action == "copy")
File.Copy(path, destPath, overwrite: true);
else
File.Move(path, destPath, overwrite: true);
return ToolResult.Ok($"{action}: {path} → {destPath}", filePath: destPath);
}
if (Directory.Exists(path))
{
if (action == "copy")
CopyDirectory(path, destPath);
else
Directory.Move(path, destPath);
return ToolResult.Ok($"{action}: {path} → {destPath}", filePath: destPath);
}
return ToolResult.Fail($"소스 경로를 찾을 수 없습니다: {path}");
default:
return ToolResult.Fail($"Unknown action: {action}");
}
}
catch (Exception ex)
{
return ToolResult.Fail($"파일 관리 오류: {ex.Message}");
}
}
private static void CopyDirectory(string src, string dst)
{
Directory.CreateDirectory(dst);
foreach (var file in Directory.GetFiles(src))
File.Copy(file, Path.Combine(dst, Path.GetFileName(file)), true);
foreach (var dir in Directory.GetDirectories(src))
CopyDirectory(dir, Path.Combine(dst, Path.GetFileName(dir)));
}
}

View File

@@ -0,0 +1,67 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>파일 내용을 읽어 반환하는 도구.</summary>
public class FileReadTool : IAgentTool
{
public string Name => "file_read";
public string Description => "Read the contents of a file. Returns the text content with line numbers.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "File path to read (absolute or relative to work folder)" },
["offset"] = new() { Type = "integer", Description = "Starting line number (1-based). Optional, default 1." },
["limit"] = new() { Type = "integer", Description = "Maximum number of lines to read. Optional, default 500." },
},
Required = ["path"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("path", out var pathEl))
return Task.FromResult(ToolResult.Fail("path가 필요합니다."));
var path = pathEl.GetString() ?? "";
var offset = args.TryGetProperty("offset", out var ofs) ? ofs.GetInt32() : 1;
var limit = args.TryGetProperty("limit", out var lim) ? lim.GetInt32() : 500;
var fullPath = ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {fullPath}"));
if (!File.Exists(fullPath))
return Task.FromResult(ToolResult.Fail($"파일이 존재하지 않습니다: {fullPath}"));
try
{
var read = TextFileCodec.ReadAllText(fullPath);
var lines = TextFileCodec.SplitLines(read.Text);
var total = lines.Length;
var startIdx = Math.Max(0, offset - 1);
var endIdx = Math.Min(total, startIdx + limit);
var sb = new System.Text.StringBuilder();
sb.AppendLine($"[{fullPath}] ({total} lines, showing {startIdx + 1}-{endIdx}, encoding: {read.Encoding.WebName})");
for (int i = startIdx; i < endIdx; i++)
sb.AppendLine($"{i + 1,5}\t{lines[i].TrimEnd('\r')}");
return Task.FromResult(ToolResult.Ok(sb.ToString(), fullPath));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"파일 읽기 실패: {ex.Message}"));
}
}
internal static string ResolvePath(string path, string workFolder)
{
if (Path.IsPathRooted(path)) return Path.GetFullPath(path);
if (!string.IsNullOrEmpty(workFolder))
return Path.GetFullPath(Path.Combine(workFolder, path));
return Path.GetFullPath(path);
}
}

View File

@@ -0,0 +1,182 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 지정 경로의 파일 변경을 감지하고 변경 내역을 반환하는 도구.
/// FileSystemInfo의 타임스탬프 기반으로 최근 변경 파일을 조회합니다.
/// </summary>
public class FileWatchTool : IAgentTool
{
public string Name => "file_watch";
public string Description =>
"Detect recent file changes in a folder. Returns a list of created, modified, and deleted files " +
"since a given time. Useful for monitoring data folders, detecting log updates, " +
"or tracking file system changes.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new()
{
Type = "string",
Description = "Folder path to watch. Relative to work folder."
},
["pattern"] = new()
{
Type = "string",
Description = "File pattern filter (e.g. '*.csv', '*.log', '*.xlsx'). Default: '*' (all files)"
},
["since"] = new()
{
Type = "string",
Description = "Time threshold: ISO 8601 datetime (e.g. '2026-03-30T09:00:00') " +
"or relative duration ('1h', '6h', '24h', '7d', '30d'). Default: '24h'"
},
["recursive"] = new()
{
Type = "boolean",
Description = "Search subdirectories recursively. Default: true"
},
["include_size"] = new()
{
Type = "boolean",
Description = "Include file sizes in output. Default: true"
},
["top_n"] = new()
{
Type = "integer",
Description = "Limit results to most recent N files. Default: 50"
},
},
Required = ["path"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var pattern = args.TryGetProperty("pattern", out var patEl) ? patEl.GetString() ?? "*" : "*";
var sinceStr = args.TryGetProperty("since", out var sinceEl) ? sinceEl.GetString() ?? "24h" : "24h";
var recursive = !args.TryGetProperty("recursive", out var recEl) || recEl.GetBoolean(); // default true
var includeSize = !args.TryGetProperty("include_size", out var sizeEl) || sizeEl.GetBoolean();
var topN = args.TryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var n) ? n : 50;
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {fullPath}"));
if (!Directory.Exists(fullPath))
return Task.FromResult(ToolResult.Fail($"폴더 없음: {fullPath}"));
try
{
var since = ParseSince(sinceStr);
var searchOpt = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
var files = Directory.GetFiles(fullPath, pattern, searchOpt)
.Select(f => new FileInfo(f))
.Where(fi => fi.LastWriteTime >= since || fi.CreationTime >= since)
.OrderByDescending(fi => fi.LastWriteTime)
.Take(topN)
.ToList();
if (files.Count == 0)
return Task.FromResult(ToolResult.Ok(
$"📂 {sinceStr} 이내 변경된 파일이 없습니다. (경로: {path}, 패턴: {pattern})"));
var sb = new StringBuilder();
sb.AppendLine($"📂 파일 변경 감지: {files.Count}개 파일 ({sinceStr} 이내)");
sb.AppendLine($" 경로: {path} | 패턴: {pattern}");
sb.AppendLine();
// 생성/수정 분류
var created = files.Where(f => f.CreationTime >= since && f.CreationTime == f.LastWriteTime).ToList();
var modified = files.Where(f => f.LastWriteTime >= since && f.CreationTime < since).ToList();
var recentlyCreated = files.Where(f => f.CreationTime >= since && f.CreationTime != f.LastWriteTime).ToList();
if (created.Count > 0)
{
sb.AppendLine($"🆕 신규 생성 ({created.Count}개):");
foreach (var f in created)
AppendFileInfo(sb, f, fullPath, includeSize);
sb.AppendLine();
}
if (modified.Count > 0)
{
sb.AppendLine($"✏️ 수정됨 ({modified.Count}개):");
foreach (var f in modified)
AppendFileInfo(sb, f, fullPath, includeSize);
sb.AppendLine();
}
if (recentlyCreated.Count > 0)
{
sb.AppendLine($"📝 생성 후 수정됨 ({recentlyCreated.Count}개):");
foreach (var f in recentlyCreated)
AppendFileInfo(sb, f, fullPath, includeSize);
sb.AppendLine();
}
// 요약 통계
var totalSize = files.Sum(f => f.Length);
sb.AppendLine($"── 요약: 총 {files.Count}개 파일, {FormatSize(totalSize)}");
// 파일 유형별 분포
var byExt = files.GroupBy(f => f.Extension.ToLowerInvariant())
.OrderByDescending(g => g.Count())
.Take(10);
sb.Append(" 유형: ");
sb.AppendLine(string.Join(", ", byExt.Select(g => $"{g.Key}({g.Count()})")));
return Task.FromResult(ToolResult.Ok(sb.ToString()));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"파일 감시 실패: {ex.Message}"));
}
}
private static DateTime ParseSince(string since)
{
if (DateTime.TryParse(since, out var dt))
return dt;
// 상대 시간: "1h", "24h", "7d", "30d"
var match = System.Text.RegularExpressions.Regex.Match(since, @"^(\d+)(h|d|m)$");
if (match.Success)
{
var amount = int.Parse(match.Groups[1].Value);
var unit = match.Groups[2].Value;
return unit switch
{
"h" => DateTime.Now.AddHours(-amount),
"d" => DateTime.Now.AddDays(-amount),
"m" => DateTime.Now.AddMinutes(-amount),
_ => DateTime.Now.AddHours(-24)
};
}
return DateTime.Now.AddHours(-24); // default: 24시간
}
private static void AppendFileInfo(StringBuilder sb, FileInfo f, string basePath, bool includeSize)
{
var relPath = Path.GetRelativePath(basePath, f.FullName);
var timeStr = f.LastWriteTime.ToString("MM-dd HH:mm");
if (includeSize)
sb.AppendLine($" {relPath} ({FormatSize(f.Length)}, {timeStr})");
else
sb.AppendLine($" {relPath} ({timeStr})");
}
private static string FormatSize(long bytes)
{
if (bytes < 1024) return $"{bytes}B";
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1}KB";
if (bytes < 1024 * 1024 * 1024) return $"{bytes / (1024.0 * 1024):F1}MB";
return $"{bytes / (1024.0 * 1024 * 1024):F2}GB";
}
}

View File

@@ -0,0 +1,46 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>파일 전체를 새로 쓰는 도구. 새 파일 생성 또는 기존 파일 덮어쓰기.</summary>
public class FileWriteTool : IAgentTool
{
public string Name => "file_write";
public string Description => "Write content to a file. Creates new file or overwrites existing. Parent directories are created automatically.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "File path to write (absolute or relative to work folder)" },
["content"] = new() { Type = "string", Description = "Content to write to the file" },
},
Required = ["path", "content"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var content = args.GetProperty("content").GetString() ?? "";
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
try
{
await TextFileCodec.WriteAllTextAsync(fullPath, content, TextFileCodec.Utf8NoBom, ct);
var lines = content.Split('\n').Length;
return ToolResult.Ok($"파일 저장 완료: {fullPath} ({lines} lines, {content.Length} chars)", fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"파일 쓰기 실패: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,171 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 작업 폴더의 디렉토리 트리 구조를 생성하는 도구.
/// LLM이 프로젝트 전체 구조를 파악하고 적절한 파일을 찾을 수 있도록 돕습니다.
/// </summary>
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.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["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." },
},
Required = []
};
// 무시할 디렉토리 (빌드 산출물, 패키지 캐시 등)
private static readonly HashSet<string> IgnoredDirs = new(StringComparer.OrdinalIgnoreCase)
{
"bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode",
"__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build",
"packages", ".nuget", "TestResults", "coverage", ".next",
"target", ".gradle", ".cargo",
};
private const int MaxEntries = 500;
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var subPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
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();
var includeFiles = true;
if (args.TryGetProperty("include_files", out var inc))
{
if (inc.ValueKind == JsonValueKind.True || inc.ValueKind == JsonValueKind.False)
includeFiles = inc.GetBoolean();
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);
var baseDir = string.IsNullOrEmpty(subPath)
? context.WorkFolder
: FileReadTool.ResolvePath(subPath, context.WorkFolder);
if (string.IsNullOrEmpty(baseDir) || !Directory.Exists(baseDir))
return Task.FromResult(ToolResult.Fail($"디렉토리가 존재하지 않습니다: {baseDir}"));
if (!context.IsPathAllowed(baseDir))
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {baseDir}"));
try
{
var sb = new StringBuilder();
var dirName = Path.GetFileName(baseDir);
if (string.IsNullOrEmpty(dirName)) dirName = baseDir;
sb.AppendLine($"{dirName}/");
int entryCount = 0;
BuildTree(sb, baseDir, "", 0, maxDepth, includeFiles, extFilter, context, ref entryCount);
if (entryCount >= MaxEntries)
sb.AppendLine($"\n... ({MaxEntries}개 항목 제한 도달, depth 또는 pattern을 조정하세요)");
var summary = $"폴더 맵 생성 완료 ({entryCount}개 항목, 깊이 {maxDepth})";
return Task.FromResult(ToolResult.Ok($"{summary}\n\n{sb}"));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"폴더 맵 생성 실패: {ex.Message}"));
}
}
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;
// 하위 디렉토리
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; } // 접근 불가 디렉토리 무시
// 하위 파일
List<FileInfo> files = [];
if (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)
.ToList();
}
catch { /* ignore */ }
}
var totalItems = subDirs.Count + files.Count;
var index = 0;
// 디렉토리 출력
foreach (var sub in subDirs)
{
if (entryCount >= MaxEntries) break;
index++;
var isLast = index == totalItems;
var connector = isLast ? "└── " : "├── ";
var childPrefix = isLast ? " " : "│ ";
sb.AppendLine($"{prefix}{connector}{sub.Name}/");
entryCount++;
if (context.IsPathAllowed(sub.FullName))
BuildTree(sb, sub.FullName, prefix + childPrefix, currentDepth + 1, maxDepth,
includeFiles, extFilter, context, ref entryCount);
}
// 파일 출력
foreach (var file in files)
{
if (entryCount >= MaxEntries) break;
index++;
var isLast = index == totalItems;
var connector = isLast ? "└── " : "├── ";
var sizeStr = FormatSize(file.Length);
sb.AppendLine($"{prefix}{connector}{file.Name} ({sizeStr})");
entryCount++;
}
}
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",
};
}

View File

@@ -0,0 +1,191 @@
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Markdig;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 문서 포맷 변환 도구.
/// Markdown → HTML, HTML → 텍스트, CSV → HTML 테이블 등 경량 변환을 수행합니다.
/// 복잡한 변환(DOCX↔HTML)은 원본 파일을 읽고 적절한 생성 스킬(docx_create, html_create)로
/// 재생성하도록 LLM에 안내합니다.
/// </summary>
public class FormatConvertTool : IAgentTool
{
public string Name => "format_convert";
public string Description =>
"Convert a document between formats. Supports: " +
"md→html (Markdown to styled HTML with mood CSS), " +
"html→text (strip HTML tags to plain text), " +
"csv→html (CSV to HTML table). " +
"For complex conversions (docx↔html, xlsx↔csv), read the source with document_read/file_read, " +
"then use the appropriate creation skill (html_create, docx_create, etc.).";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["source"] = new() { Type = "string", Description = "Source file path to convert" },
["target"] = new() { Type = "string", Description = "Target output file path (extension determines format)" },
["mood"] = new() { Type = "string", Description = "Design mood for HTML output (default: modern). Only used for md→html conversion." },
},
Required = ["source", "target"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var source = args.GetProperty("source").GetString() ?? "";
var target = args.GetProperty("target").GetString() ?? "";
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "modern" : "modern";
var srcPath = FileReadTool.ResolvePath(source, context.WorkFolder);
var tgtPath = FileReadTool.ResolvePath(target, context.WorkFolder);
if (context.ActiveTab == "Cowork") tgtPath = AgentContext.EnsureTimestampedPath(tgtPath);
if (!context.IsPathAllowed(srcPath))
return ToolResult.Fail($"소스 경로 접근 차단: {srcPath}");
if (!context.IsPathAllowed(tgtPath))
return ToolResult.Fail($"대상 경로 접근 차단: {tgtPath}");
if (!File.Exists(srcPath))
return ToolResult.Fail($"소스 파일 없음: {srcPath}");
if (!await context.CheckWritePermissionAsync("format_convert", tgtPath))
return ToolResult.Fail("쓰기 권한이 거부되었습니다.");
var srcExt = Path.GetExtension(srcPath).ToLowerInvariant();
var tgtExt = Path.GetExtension(tgtPath).ToLowerInvariant();
var convKey = $"{srcExt}→{tgtExt}";
try
{
var srcContent = (await TextFileCodec.ReadAllTextAsync(srcPath, ct)).Text;
string result;
switch (convKey)
{
case ".md→.html":
var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
var bodyHtml = Markdown.ToHtml(srcContent, pipeline);
var css = TemplateService.GetCss(mood);
result = $"<!DOCTYPE html>\n<html lang=\"ko\">\n<head>\n<meta charset=\"UTF-8\"/>\n" +
$"<style>{css}</style>\n</head>\n<body>\n<div class=\"container\">\n{bodyHtml}\n</div>\n</body>\n</html>";
break;
case ".html→.txt" or ".htm→.txt":
result = StripHtmlTags(srcContent);
break;
case ".csv→.html":
result = CsvToHtmlTable(srcContent, mood);
break;
case ".md→.txt":
result = StripMarkdown(srcContent);
break;
default:
return ToolResult.Fail(
$"직접 변환 미지원: {convKey}\n" +
"대안: source를 file_read/document_read로 읽은 뒤, " +
"적절한 생성 스킬(html_create, docx_create, excel_create 등)을 사용하세요.");
}
await TextFileCodec.WriteAllTextAsync(tgtPath, result, TextFileCodec.Utf8NoBom, ct);
var srcName = Path.GetFileName(srcPath);
var tgtName = Path.GetFileName(tgtPath);
return ToolResult.Ok(
$"변환 완료: {srcName} → {tgtName}\n" +
$"변환 유형: {convKey}\n" +
$"출력 크기: {result.Length:N0}자",
tgtPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"변환 오류: {ex.Message}");
}
}
private static string StripHtmlTags(string html)
{
// script/style 제거
var cleaned = Regex.Replace(html, @"<(script|style)[^>]*>.*?</\1>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase);
// 줄바꿈 태그 처리
cleaned = Regex.Replace(cleaned, @"<br\s*/?>", "\n", RegexOptions.IgnoreCase);
cleaned = Regex.Replace(cleaned, @"</(p|div|h[1-6]|li|tr)>", "\n", RegexOptions.IgnoreCase);
// 태그 제거
cleaned = Regex.Replace(cleaned, @"<[^>]+>", "");
// HTML 엔티티 디코딩
cleaned = System.Net.WebUtility.HtmlDecode(cleaned);
// 연속 빈줄 정리
cleaned = Regex.Replace(cleaned, @"\n{3,}", "\n\n");
return cleaned.Trim();
}
private static string StripMarkdown(string md)
{
var result = md;
result = Regex.Replace(result, @"^#{1,6}\s+", "", RegexOptions.Multiline); // 헤딩
result = Regex.Replace(result, @"\*\*(.+?)\*\*", "$1"); // 볼드
result = Regex.Replace(result, @"\*(.+?)\*", "$1"); // 이탤릭
result = Regex.Replace(result, @"`(.+?)`", "$1"); // 인라인 코드
result = Regex.Replace(result, @"^\s*[-*+]\s+", "", RegexOptions.Multiline); // 리스트
result = Regex.Replace(result, @"^\s*\d+\.\s+", "", RegexOptions.Multiline); // 번호 리스트
result = Regex.Replace(result, @"\[(.+?)\]\(.+?\)", "$1"); // 링크
return result.Trim();
}
private static string CsvToHtmlTable(string csv, string mood)
{
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lines.Length == 0) return "<p>빈 CSV 파일</p>";
var cssStr = TemplateService.GetCss(mood);
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>\n<html lang=\"ko\">\n<head>\n<meta charset=\"UTF-8\"/>");
sb.AppendLine($"<style>{cssStr}</style>\n</head>\n<body>\n<div class=\"container\">");
sb.AppendLine("<table><thead><tr>");
var headers = ParseCsvLine(lines[0]);
foreach (var h in headers) sb.Append($"<th>{System.Net.WebUtility.HtmlEncode(h)}</th>");
sb.AppendLine("</tr></thead><tbody>");
for (int i = 1; i < Math.Min(lines.Length, 1001); i++)
{
var vals = ParseCsvLine(lines[i]);
sb.Append("<tr>");
foreach (var v in vals) sb.Append($"<td>{System.Net.WebUtility.HtmlEncode(v)}</td>");
sb.AppendLine("</tr>");
}
sb.AppendLine("</tbody></table>\n</div>\n</body>\n</html>");
return sb.ToString();
}
private static string[] ParseCsvLine(string line)
{
var fields = new List<string>();
var current = new StringBuilder();
bool inQuotes = false;
for (int i = 0; i < line.Length; i++)
{
var c = line[i];
if (inQuotes)
{
if (c == '"' && i + 1 < line.Length && line[i + 1] == '"') { current.Append('"'); i++; }
else if (c == '"') inQuotes = false;
else current.Append(c);
}
else
{
if (c == '"') inQuotes = true;
else if (c == ',') { fields.Add(current.ToString()); current.Clear(); }
else current.Append(c);
}
}
fields.Add(current.ToString());
return fields.ToArray();
}
}

View File

@@ -0,0 +1,203 @@
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Git 버전 관리 도구.
/// 사내 GitHub Enterprise 환경을 고려하여 안전한 Git 작업을 지원합니다.
/// push/force push는 차단되며, 사용자가 직접 수행해야 합니다.
/// </summary>
public class GitTool : IAgentTool
{
public string Name => "git_tool";
public string Description =>
"Execute safe Git operations. Supports: status, diff, log, add, commit, branch, checkout. " +
"Push operations are blocked for safety — user must push manually. " +
"Works with enterprise GitHub (on-premise) repositories.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "Git action: status, diff, log, add, commit, branch, checkout, stash, remote",
Enum = ["status", "diff", "log", "add", "commit", "branch", "checkout", "stash", "remote"],
},
["args"] = new()
{
Type = "string",
Description = "Additional arguments. For commit: commit message. For add: file path(s). For log: '--oneline -10'. For diff: file path or '--staged'.",
},
},
Required = ["action"]
};
// 차단 명령 패턴 — 원격 수정 및 위험 작업
private static readonly string[] BlockedPatterns =
[
"push", "push --force", "push -f",
"pull", "fetch",
"reset --hard", "clean -f",
"rebase", "merge",
"remote add", "remote remove", "remote set-url",
"branch -D", "branch -d",
"tag -d", "tag -D",
];
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("action", out var actionEl))
return ToolResult.Fail("action이 필요합니다.");
var action = actionEl.GetString() ?? "status";
var extraArgs = args.TryGetProperty("args", out var a) ? a.GetString() ?? "" : "";
var workDir = context.WorkFolder;
if (string.IsNullOrEmpty(workDir) || !Directory.Exists(workDir))
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
// Git 설치 확인
var gitPath = FindGit();
if (gitPath == null)
return ToolResult.Fail("Git이 설치되어 있지 않습니다. PATH에 git이 있는지 확인하세요.");
// Git 저장소 확인
if (!Directory.Exists(Path.Combine(workDir, ".git")))
{
// 상위 디렉토리에서 .git 확인 (서브디렉토리 작업 지원)
var checkDir = workDir;
bool found = false;
while (!string.IsNullOrEmpty(checkDir))
{
if (Directory.Exists(Path.Combine(checkDir, ".git"))) { found = true; break; }
var parent = Directory.GetParent(checkDir)?.FullName;
if (parent == checkDir) break;
checkDir = parent;
}
if (!found)
return ToolResult.Fail("현재 작업 폴더는 Git 저장소가 아닙니다.");
}
// 명령 구성
var gitCommand = action switch
{
"status" => "status --short --branch",
"diff" => string.IsNullOrEmpty(extraArgs) ? "diff" : $"diff {extraArgs}",
"log" => string.IsNullOrEmpty(extraArgs) ? "log --oneline -15" : $"log {extraArgs}",
"add" => string.IsNullOrEmpty(extraArgs) ? "add -A" : $"add {extraArgs}",
"commit" => string.IsNullOrEmpty(extraArgs)
? null // 커밋 메시지 필수
: $"commit -m \"{extraArgs.Replace("\"", "\\\"")}\"",
"branch" => string.IsNullOrEmpty(extraArgs) ? "branch -a" : $"branch {extraArgs}",
"checkout" => string.IsNullOrEmpty(extraArgs) ? null : $"checkout {extraArgs}",
"stash" => string.IsNullOrEmpty(extraArgs) ? "stash list" : $"stash {extraArgs}",
"remote" => "remote -v",
_ => null,
};
if (gitCommand == null)
{
if (action == "commit")
return ToolResult.Fail("커밋 메시지가 필요합니다. args에 커밋 메시지를 지정하세요.");
if (action == "checkout")
return ToolResult.Fail("체크아웃할 브랜치/파일을 args에 지정하세요.");
return ToolResult.Fail($"알 수 없는 액션: {action}");
}
// 위험 명령 차단
var fullCmd = $"git {gitCommand}";
foreach (var pattern in BlockedPatterns)
{
if (fullCmd.Contains(pattern, StringComparison.OrdinalIgnoreCase))
return ToolResult.Fail(
$"안전을 위해 '{pattern}' 작업은 차단됩니다.\n" +
"원격 저장소 작업(push/pull/fetch)과 이력 변경 작업은 사용자가 직접 수행하세요.");
}
// 쓰기 작업은 권한 확인
var writeActions = new HashSet<string> { "add", "commit", "checkout", "stash" };
if (writeActions.Contains(action))
{
if (!await context.CheckWritePermissionAsync(Name, workDir))
return ToolResult.Fail("Git 쓰기 권한이 거부되었습니다.");
}
// Git 커밋 — 현재 비활성 (향후 활성화 예정)
// 의사결정 수준에서 무조건 확인을 받더라도, 커밋 자체를 차단합니다.
if (action == "commit")
{
return ToolResult.Fail(
"Git 커밋 기능은 현재 비활성 상태입니다.\n" +
"안전을 위해 커밋은 사용자가 직접 수행하세요.\n" +
"향후 버전에서 활성화될 예정입니다.");
}
// 명령 실행
try
{
var psi = new ProcessStartInfo(gitPath, gitCommand)
{
WorkingDirectory = workDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(30));
using var proc = Process.Start(psi);
if (proc == null) return ToolResult.Fail("Git 프로세스 시작 실패");
var stdout = await proc.StandardOutput.ReadToEndAsync(cts.Token);
var stderr = await proc.StandardError.ReadToEndAsync(cts.Token);
await proc.WaitForExitAsync(cts.Token);
// 출력 제한
if (stdout.Length > 8000) stdout = stdout[..8000] + "\n... (출력 잘림)";
var sb = new StringBuilder();
sb.AppendLine($"[git {action}] Exit code: {proc.ExitCode}");
if (!string.IsNullOrWhiteSpace(stdout)) sb.Append(stdout);
if (!string.IsNullOrWhiteSpace(stderr) && proc.ExitCode != 0) sb.AppendLine($"\n[stderr] {stderr.Trim()}");
return proc.ExitCode == 0
? ToolResult.Ok(sb.ToString())
: ToolResult.Fail(sb.ToString());
}
catch (OperationCanceledException)
{
return ToolResult.Fail("Git 명령 타임아웃 (30초)");
}
catch (Exception ex)
{
return ToolResult.Fail($"Git 실행 오류: {ex.Message}");
}
}
private static string? FindGit()
{
try
{
var psi = new ProcessStartInfo("where.exe", "git")
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var proc = Process.Start(psi);
if (proc == null) return null;
var output = proc.StandardOutput.ReadToEnd().Trim();
proc.WaitForExit(5000);
return string.IsNullOrWhiteSpace(output) ? null : output.Split('\n')[0].Trim();
}
catch { return null; }
}
}

View File

@@ -0,0 +1,69 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>파일 패턴 검색 도구. glob 패턴으로 파일 목록을 반환합니다.</summary>
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 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." },
},
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 baseDir = string.IsNullOrEmpty(searchPath)
? context.WorkFolder
: FileReadTool.ResolvePath(searchPath, context.WorkFolder);
if (string.IsNullOrEmpty(baseDir) || !Directory.Exists(baseDir))
return Task.FromResult(ToolResult.Fail($"디렉토리가 존재하지 않습니다: {baseDir}"));
if (!context.IsPathAllowed(baseDir))
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {baseDir}"));
try
{
// glob 패턴을 Directory.EnumerateFiles용으로 변환
var searchPattern = ExtractSearchPattern(pattern);
var recursive = pattern.Contains("**") || pattern.Contains('/') || pattern.Contains('\\');
var option = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
var files = Directory.EnumerateFiles(baseDir, searchPattern, option)
.Where(f => context.IsPathAllowed(f))
.OrderBy(f => f)
.Take(200)
.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}"));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"검색 실패: {ex.Message}"));
}
}
private static string ExtractSearchPattern(string globPattern)
{
// **/*.cs → *.cs, src/**/*.json → *.json
var parts = globPattern.Replace('/', '\\').Split('\\');
var last = parts[^1];
return string.IsNullOrEmpty(last) || last == "**" ? "*" : last;
}
}

View File

@@ -0,0 +1,134 @@
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>파일 내용 텍스트 검색 도구. 정규식을 지원합니다.</summary>
public class GrepTool : IAgentTool
{
public string Name => "grep";
public string Description => "Search file contents for a pattern (regex supported). Returns matching lines with file paths and line numbers.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["pattern"] = new() { Type = "string", Description = "Search pattern (regex supported)" },
["path"] = new() { Type = "string", Description = "File or directory to search in. Optional, defaults to work folder." },
["glob"] = new() { Type = "string", Description = "File pattern filter (e.g. '*.cs', '*.json'). Optional." },
["context_lines"] = new() { Type = "integer", Description = "Number of context lines before/after each match (0-5). Default 0." },
["case_sensitive"] = new() { Type = "boolean", Description = "Case-sensitive search. Default false (case-insensitive)." },
},
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 globFilter = args.TryGetProperty("glob", out var g) ? g.GetString() ?? "" : "";
var contextLines = args.TryGetProperty("context_lines", out var cl) ? Math.Clamp(cl.GetInt32(), 0, 5) : 0;
var caseSensitive = args.TryGetProperty("case_sensitive", out var cs) && cs.GetBoolean();
var baseDir = string.IsNullOrEmpty(searchPath)
? context.WorkFolder
: FileReadTool.ResolvePath(searchPath, context.WorkFolder);
if (string.IsNullOrEmpty(baseDir))
return Task.FromResult(ToolResult.Fail("작업 폴더가 설정되지 않았습니다."));
try
{
var regexOpts = RegexOptions.Compiled | (caseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase);
var regex = new Regex(pattern, regexOpts, TimeSpan.FromSeconds(5));
var filePattern = string.IsNullOrEmpty(globFilter) ? "*" : globFilter;
IEnumerable<string> files;
if (File.Exists(baseDir))
files = [baseDir];
else if (Directory.Exists(baseDir))
files = Directory.EnumerateFiles(baseDir, filePattern, SearchOption.AllDirectories);
else
return Task.FromResult(ToolResult.Fail($"경로가 존재하지 않습니다: {baseDir}"));
var sb = new StringBuilder();
int matchCount = 0;
int fileCount = 0;
const int maxMatches = 100;
foreach (var file in files)
{
if (ct.IsCancellationRequested) break;
if (!context.IsPathAllowed(file)) continue;
if (IsBinaryFile(file)) continue;
try
{
var read = TextFileCodec.ReadAllText(file);
var lines = TextFileCodec.SplitLines(read.Text);
bool fileHit = false;
for (int i = 0; i < lines.Length && matchCount < maxMatches; i++)
{
if (regex.IsMatch(lines[i]))
{
if (!fileHit)
{
var rel = Directory.Exists(context.WorkFolder)
? Path.GetRelativePath(context.WorkFolder, file)
: file;
sb.AppendLine($"\n{rel}:");
fileHit = true;
fileCount++;
}
// 컨텍스트 라인 (before)
if (contextLines > 0)
{
for (int c = Math.Max(0, i - contextLines); c < i; c++)
sb.AppendLine($" {c + 1} {lines[c].TrimEnd()}");
}
sb.AppendLine($" {i + 1}: {lines[i].TrimEnd()}");
// 컨텍스트 라인 (after)
if (contextLines > 0)
{
for (int c = i + 1; c <= Math.Min(lines.Length - 1, i + contextLines); c++)
sb.AppendLine($" {c + 1} {lines[c].TrimEnd()}");
sb.AppendLine(" ---");
}
matchCount++;
}
}
}
catch { /* 읽기 실패 파일 무시 */ }
if (matchCount >= maxMatches) break;
}
if (matchCount == 0)
return Task.FromResult(ToolResult.Ok($"패턴 '{pattern}'에 일치하는 결과가 없습니다."));
var header = $"{fileCount}개 파일에서 {matchCount}개 일치{(matchCount >= maxMatches ? " ( )" : "")}:";
return Task.FromResult(ToolResult.Ok(header + sb));
}
catch (RegexParseException)
{
return Task.FromResult(ToolResult.Fail($"잘못된 정규식 패턴: {pattern}"));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"검색 실패: {ex.Message}"));
}
}
private static bool IsBinaryFile(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
return ext is ".exe" or ".dll" or ".zip" or ".7z" or ".rar" or ".tar" or ".gz"
or ".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".ico" or ".webp"
or ".pdf" or ".docx" or ".xlsx" or ".pptx"
or ".mp3" or ".mp4" or ".avi" or ".mov" or ".mkv"
or ".psd" or ".msi" or ".iso" or ".bin" or ".dat" or ".db";
}
}

View File

@@ -0,0 +1,85 @@
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>파일·텍스트 MD5/SHA256 해시 계산 도구.</summary>
public class HashTool : IAgentTool
{
public string Name => "hash_tool";
public string Description =>
"Compute hash digests for text or files. " +
"Supports MD5, SHA1, SHA256, SHA512. " +
"Use 'text' mode for inline text or 'file' mode for file path.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["mode"] = new()
{
Type = "string",
Description = "Input mode",
Enum = ["text", "file"],
},
["input"] = new()
{
Type = "string",
Description = "Text to hash or file path",
},
["algorithm"] = new()
{
Type = "string",
Description = "Hash algorithm (default: sha256)",
Enum = ["md5", "sha1", "sha256", "sha512"],
},
},
Required = ["mode", "input"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var mode = args.GetProperty("mode").GetString() ?? "text";
var input = args.GetProperty("input").GetString() ?? "";
var algo = args.TryGetProperty("algorithm", out var a) ? a.GetString() ?? "sha256" : "sha256";
try
{
byte[] data;
string label;
if (mode == "file")
{
var path = Path.IsPathRooted(input) ? input : Path.Combine(context.WorkFolder, input);
if (!File.Exists(path))
return Task.FromResult(ToolResult.Fail($"File not found: {path}"));
data = File.ReadAllBytes(path);
label = Path.GetFileName(path);
}
else
{
data = Encoding.UTF8.GetBytes(input);
label = $"text ({data.Length} bytes)";
}
using var hasher = algo switch
{
"md5" => (HashAlgorithm)MD5.Create(),
"sha1" => SHA1.Create(),
"sha256" => SHA256.Create(),
"sha512" => SHA512.Create(),
_ => SHA256.Create(),
};
var hash = hasher.ComputeHash(data);
var hex = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
return Task.FromResult(ToolResult.Ok($"{algo.ToUpperInvariant()}({label}):\n{hex}"));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"해시 오류: {ex.Message}"));
}
}
}

View File

@@ -0,0 +1,249 @@
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>
/// HTML (.html) 보고서를 생성하는 내장 스킬.
/// 테마 무드(mood)를 선택하면 TemplateService에서 해당 CSS를 가져와 적용합니다.
/// TOC, 커버 페이지, 섹션 번호 등 고급 문서 기능을 지원합니다.
/// </summary>
public class HtmlSkill : IAgentTool
{
public string Name => "html_create";
public string Description => "Create a styled HTML (.html) document with rich formatting. " +
"Supports: table of contents (toc), cover page, callouts (.callout-info/warning/tip/danger), " +
"badges (.badge-blue/green/red/yellow/purple), CSS bar charts (.chart-bar), " +
"progress bars (.progress), timelines (.timeline), grid layouts (.grid-2/3/4), " +
"and auto section numbering. " +
"Available moods: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "Output file path (.html). Relative to work folder." },
["title"] = new() { Type = "string", Description = "Document title (shown in browser tab and header)" },
["body"] = new() { Type = "string", Description = "HTML body content. Use semantic tags: h2/h3 for sections, " +
"div.callout-info/warning/tip/danger for callouts, span.badge-blue/green/red for badges, " +
"div.chart-bar>div.bar-item for charts, div.grid-2/3/4 for grid layouts, " +
"div.timeline>div.timeline-item for timelines, div.progress for progress bars." },
["mood"] = new() { Type = "string", Description = "Design template mood: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard. Default: modern" },
["style"] = new() { Type = "string", Description = "Optional additional CSS. Appended after mood+shared CSS." },
["toc"] = new() { Type = "boolean", Description = "Auto-generate table of contents from h2/h3 headings. Default: false" },
["numbered"] = new() { Type = "boolean", Description = "Auto-number h2/h3 sections (1., 1-1., etc). Default: false" },
["cover"] = new()
{
Type = "object",
Description = "Cover page config: {\"title\": \"...\", \"subtitle\": \"...\", \"author\": \"...\", \"date\": \"...\", \"gradient\": \"#hex1,#hex2\"}. Omit to skip cover page."
},
},
Required = ["path", "title", "body"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var title = args.GetProperty("title").GetString() ?? "Report";
var body = args.GetProperty("body").GetString() ?? "";
var customStyle = args.TryGetProperty("style", out var s) ? s.GetString() : null;
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "modern" : "modern";
var useToc = args.TryGetProperty("toc", out var tocVal) && tocVal.GetBoolean();
var useNumbered = args.TryGetProperty("numbered", out var numVal) && numVal.GetBoolean();
var hasCover = args.TryGetProperty("cover", out var coverVal) && coverVal.ValueKind == JsonValueKind.Object;
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
if (!fullPath.EndsWith(".html", StringComparison.OrdinalIgnoreCase) &&
!fullPath.EndsWith(".htm", StringComparison.OrdinalIgnoreCase))
fullPath += ".html";
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
try
{
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
// 스타일 결정: mood CSS + shared CSS + custom
var style = TemplateService.GetCss(mood);
if (!string.IsNullOrEmpty(customStyle))
style += "\n" + customStyle;
var moodInfo = TemplateService.GetMood(mood);
var moodLabel = moodInfo != null ? $" · {moodInfo.Icon} {moodInfo.Label}" : "";
// 섹션 번호 자동 부여 — h2, h3에 class="numbered" 추가
if (useNumbered)
body = AddNumberedClass(body);
// h2/h3에서 id 속성 자동 부여 (TOC 앵커용)
body = EnsureHeadingIds(body);
// TOC 생성
var tocHtml = useToc ? GenerateToc(body) : "";
// 커버 페이지 생성
var coverHtml = hasCover ? GenerateCover(coverVal, title) : "";
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"ko\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{Escape(title)}</title>");
sb.AppendLine($"<style>{style}</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.AppendLine("<div class=\"container\">");
// 커버 페이지
if (!string.IsNullOrEmpty(coverHtml))
{
sb.AppendLine(coverHtml);
}
else
{
// 커버가 없으면 기존 방식의 제목+메타
sb.AppendLine($"<h1>{Escape(title)}</h1>");
sb.AppendLine($"<div class=\"meta\">생성: {DateTime.Now:yyyy-MM-dd HH:mm} | AX Copilot{moodLabel}</div>");
}
// TOC
if (!string.IsNullOrEmpty(tocHtml))
sb.AppendLine(tocHtml);
// 본문
sb.AppendLine(body);
sb.AppendLine("</div>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
await File.WriteAllTextAsync(fullPath, sb.ToString(), Encoding.UTF8, ct);
var features = new List<string>();
if (useToc) features.Add("목차");
if (useNumbered) features.Add("섹션번호");
if (hasCover) features.Add("커버페이지");
var featureStr = features.Count > 0 ? $" [{string.Join(", ", features)}]" : "";
return ToolResult.Ok(
$"HTML 문서 생성 완료: {fullPath} (디자인: {mood}{featureStr})",
fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"HTML 생성 실패: {ex.Message}");
}
}
/// <summary>h2, h3 태그에 id 속성이 없으면 자동 부여</summary>
private static string EnsureHeadingIds(string html)
{
int counter = 0;
return Regex.Replace(html, @"<(h[23])(\s[^>]*)?>", match =>
{
var tag = match.Groups[1].Value;
var attrs = match.Groups[2].Value;
counter++;
// 이미 id가 있으면 그대로
if (attrs.Contains("id=", StringComparison.OrdinalIgnoreCase))
return match.Value;
return $"<{tag}{attrs} id=\"section-{counter}\">";
});
}
/// <summary>h2, h3에 class="numbered" 추가</summary>
private static string AddNumberedClass(string html)
{
return Regex.Replace(html, @"<(h[23])(\s[^>]*)?>", match =>
{
var tag = match.Groups[1].Value;
var attrs = match.Groups[2].Value;
// 이미 numbered 클래스가 있으면 그대로
if (attrs.Contains("numbered", StringComparison.OrdinalIgnoreCase))
return match.Value;
// 기존 class 속성에 추가
if (Regex.IsMatch(attrs, @"class\s*=\s*""", RegexOptions.IgnoreCase))
return Regex.Replace(match.Value, @"class\s*=\s*""", "class=\"numbered ");
return $"<{tag}{attrs} class=\"numbered\">";
});
}
/// <summary>body HTML에서 h2/h3을 파싱해 목차 HTML 생성</summary>
private static string GenerateToc(string html)
{
var headings = Regex.Matches(html, @"<(h[23])[^>]*id=""([^""]+)""[^>]*>(.*?)</\1>",
RegexOptions.IgnoreCase | RegexOptions.Singleline);
if (headings.Count == 0) return "";
var sb = new StringBuilder();
sb.AppendLine("<nav class=\"toc\">");
sb.AppendLine("<h2>📋 목차</h2>");
sb.AppendLine("<ul>");
foreach (Match h in headings)
{
var level = h.Groups[1].Value.ToLower();
var id = h.Groups[2].Value;
// 태그 내부 텍스트에서 HTML 태그 제거
var text = Regex.Replace(h.Groups[3].Value, @"<[^>]+>", "").Trim();
var cssClass = level == "h3" ? " class=\"toc-h3\"" : "";
sb.AppendLine($"<li{cssClass}><a href=\"#{id}\">{text}</a></li>");
}
sb.AppendLine("</ul>");
sb.AppendLine("</nav>");
return sb.ToString();
}
/// <summary>cover 객체에서 커버 페이지 HTML 생성</summary>
private static string GenerateCover(JsonElement cover, string fallbackTitle)
{
var coverTitle = cover.TryGetProperty("title", out var ct) ? ct.GetString() ?? fallbackTitle : fallbackTitle;
var subtitle = cover.TryGetProperty("subtitle", out var sub) ? sub.GetString() ?? "" : "";
var author = cover.TryGetProperty("author", out var auth) ? auth.GetString() ?? "" : "";
var date = cover.TryGetProperty("date", out var dt) ? dt.GetString() ?? DateTime.Now.ToString("yyyy-MM-dd") : DateTime.Now.ToString("yyyy-MM-dd");
var gradient = cover.TryGetProperty("gradient", out var grad) ? grad.GetString() : null;
var styleAttr = "";
if (!string.IsNullOrEmpty(gradient) && gradient.Contains(','))
{
var colors = gradient.Split(',');
styleAttr = $" style=\"background: linear-gradient(135deg, {colors[0].Trim()} 0%, {colors[1].Trim()} 100%)\"";
}
var sb = new StringBuilder();
sb.AppendLine($"<div class=\"cover-page\"{styleAttr}>");
sb.AppendLine($"<h1>{Escape(coverTitle)}</h1>");
if (!string.IsNullOrEmpty(subtitle))
sb.AppendLine($"<div class=\"cover-subtitle\">{Escape(subtitle)}</div>");
sb.AppendLine("<div class=\"cover-divider\"></div>");
var metaParts = new List<string>();
if (!string.IsNullOrEmpty(author)) metaParts.Add(author);
metaParts.Add(date);
metaParts.Add("AX Copilot");
sb.AppendLine($"<div class=\"cover-meta\">{Escape(string.Join(" · ", metaParts))}</div>");
sb.AppendLine("</div>");
return sb.ToString();
}
private static string Escape(string s) =>
s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}

View File

@@ -0,0 +1,147 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 로컬/사내 HTTP API 호출 도구.
/// GET/POST/PUT/DELETE 요청, JSON 파싱, 헤더 설정을 지원합니다.
/// </summary>
public class HttpTool : IAgentTool
{
public string Name => "http_tool";
public string Description =>
"Make HTTP requests to local or internal APIs. " +
"Supports GET, POST, PUT, DELETE methods with JSON body and custom headers. " +
"Only allows localhost and internal network addresses (security restriction). " +
"Use this for testing APIs, fetching data from internal services, or webhooks.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["method"] = new()
{
Type = "string",
Description = "HTTP method",
Enum = ["GET", "POST", "PUT", "DELETE", "PATCH"],
},
["url"] = new()
{
Type = "string",
Description = "Request URL (localhost or internal network only)",
},
["body"] = new()
{
Type = "string",
Description = "Request body (JSON string, for POST/PUT/PATCH)",
},
["headers"] = new()
{
Type = "string",
Description = "Custom headers as JSON object, e.g. {\"Authorization\": \"Bearer token\"}",
},
["timeout"] = new()
{
Type = "string",
Description = "Request timeout in seconds (default: 30, max: 120)",
},
},
Required = ["method", "url"],
};
private static readonly HttpClient _client = new() { Timeout = TimeSpan.FromSeconds(30) };
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var method = args.GetProperty("method").GetString()?.ToUpperInvariant() ?? "GET";
var url = args.GetProperty("url").GetString() ?? "";
var body = args.TryGetProperty("body", out var b) ? b.GetString() ?? "" : "";
var headers = args.TryGetProperty("headers", out var h) ? h.GetString() ?? "" : "";
var timeout = args.TryGetProperty("timeout", out var t) ? int.TryParse(t.GetString(), out var ts) ? Math.Min(ts, 120) : 30 : 30;
// 보안: 허용된 호스트만
if (!IsAllowedHost(url))
return ToolResult.Fail("보안 제한: localhost, 127.0.0.1, 사내 네트워크(10.x, 172.16-31.x, 192.168.x)만 허용됩니다.");
try
{
var httpMethod = new HttpMethod(method);
using var request = new HttpRequestMessage(httpMethod, url);
// 헤더 설정
if (!string.IsNullOrEmpty(headers))
{
using var headerDoc = JsonDocument.Parse(headers);
foreach (var prop in headerDoc.RootElement.EnumerateObject())
request.Headers.TryAddWithoutValidation(prop.Name, prop.Value.GetString());
}
// 본문 설정
if (!string.IsNullOrEmpty(body) && method is "POST" or "PUT" or "PATCH")
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
using var response = await _client.SendAsync(request, cts.Token);
var statusCode = (int)response.StatusCode;
var responseBody = await response.Content.ReadAsStringAsync(cts.Token);
// 응답 포맷팅
var sb = new StringBuilder();
sb.AppendLine($"HTTP {statusCode} {response.ReasonPhrase}");
sb.AppendLine($"Content-Type: {response.Content.Headers.ContentType}");
sb.AppendLine();
// JSON이면 포맷
if (response.Content.Headers.ContentType?.MediaType?.Contains("json") == true)
{
try
{
using var doc = JsonDocument.Parse(responseBody);
responseBody = JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
}
catch { /* not valid JSON, keep raw */ }
}
if (responseBody.Length > 8000)
responseBody = responseBody[..8000] + $"\n... (truncated, total {responseBody.Length} chars)";
sb.Append(responseBody);
return ToolResult.Ok(sb.ToString());
}
catch (TaskCanceledException)
{
return ToolResult.Fail($"요청 시간 초과 ({timeout}초)");
}
catch (Exception ex)
{
return ToolResult.Fail($"HTTP 요청 실패: {ex.Message}");
}
}
private static bool IsAllowedHost(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
var host = uri.Host;
if (host is "localhost" or "127.0.0.1" or "::1") return true;
// 사내 네트워크 대역
if (System.Net.IPAddress.TryParse(host, out var ip))
{
var bytes = ip.GetAddressBytes();
if (bytes.Length == 4)
{
if (bytes[0] == 10) return true; // 10.0.0.0/8
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; // 172.16.0.0/12
if (bytes[0] == 192 && bytes[1] == 168) return true; // 192.168.0.0/16
}
}
return false;
}
}

View File

@@ -0,0 +1,286 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 에이전트 도구의 공통 인터페이스.
/// LLM function calling을 통해 호출되며, JSON 파라미터를 받아 결과를 반환합니다.
/// </summary>
public interface IAgentTool
{
/// <summary>LLM에 노출되는 도구 이름 (snake_case). 예: "file_read"</summary>
string Name { get; }
/// <summary>LLM에 전달되는 도구 설명.</summary>
string Description { get; }
/// <summary>LLM function calling용 파라미터 JSON Schema.</summary>
ToolParameterSchema Parameters { get; }
/// <summary>도구를 실행하고 결과를 반환합니다.</summary>
/// <param name="args">LLM이 생성한 JSON 파라미터</param>
/// <param name="context">실행 컨텍스트 (작업 폴더, 권한 등)</param>
/// <param name="ct">취소 토큰</param>
Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default);
}
/// <summary>도구 실행 결과.</summary>
public class ToolResult
{
/// <summary>성공 여부.</summary>
public bool Success { get; init; }
/// <summary>결과 텍스트 (LLM에 피드백).</summary>
public string Output { get; init; } = "";
/// <summary>생성/수정된 파일 경로 (UI 표시용).</summary>
public string? FilePath { get; init; }
/// <summary>오류 메시지 (실패 시).</summary>
public string? Error { get; init; }
public static ToolResult Ok(string output, string? filePath = null) =>
new() { Success = true, Output = output, FilePath = filePath };
public static ToolResult Fail(string error) =>
new() { Success = false, Output = error, Error = error };
}
/// <summary>도구 파라미터 JSON Schema (LLM function calling용).</summary>
public class ToolParameterSchema
{
[JsonPropertyName("type")]
public string Type { get; init; } = "object";
[JsonPropertyName("properties")]
public Dictionary<string, ToolProperty> Properties { get; init; } = new();
[JsonPropertyName("required")]
public List<string> Required { get; init; } = new();
}
/// <summary>파라미터 속성 정의.</summary>
public class ToolProperty
{
[JsonPropertyName("type")]
public string Type { get; init; } = "string";
[JsonPropertyName("description")]
public string Description { get; init; } = "";
[JsonPropertyName("enum")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? Enum { get; init; }
/// <summary>array 타입일 때 항목 스키마. Gemini API 필수.</summary>
[JsonPropertyName("items")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ToolProperty? Items { get; init; }
}
/// <summary>에이전트 실행 컨텍스트.</summary>
public class AgentContext
{
private static readonly HashSet<string> SensitiveTools = new(StringComparer.OrdinalIgnoreCase)
{
"file_write", "file_edit", "file_manage",
"html_create", "markdown_create", "docx_create", "excel_create", "csv_create", "pptx_create",
"chart_create", "script_create", "document_assemble", "format_convert", "template_render", "checkpoint",
"process", "build_run", "git_tool", "http_tool", "open_external", "snippet_runner",
"spawn_agent", "test_loop",
};
private readonly object _permissionLock = new();
private readonly HashSet<string> _approvedPermissionCache = new(StringComparer.OrdinalIgnoreCase);
/// <summary>작업 폴더 경로.</summary>
public string WorkFolder { get; init; } = "";
/// <summary>파일 접근 권한. Ask | Auto | Deny</summary>
public string Permission { get; init; } = "Ask";
/// <summary>도구별 권한 오버라이드. 키: 도구명, 값: "ask" | "auto" | "deny".</summary>
public Dictionary<string, string> ToolPermissions { get; init; } = new();
/// <summary>차단 경로 패턴 목록.</summary>
public List<string> BlockedPaths { get; init; } = new();
/// <summary>차단 확장자 목록.</summary>
public List<string> BlockedExtensions { get; init; } = new();
/// <summary>현재 활성 탭. "Chat" | "Cowork" | "Code".</summary>
public string ActiveTab { get; init; } = "Chat";
/// <summary>운영 모드. internal(사내) | external(사외).</summary>
public string OperationMode { get; init; } = AxCopilot.Services.OperationModePolicy.InternalMode;
/// <summary>개발자 모드: 상세 이력 표시.</summary>
public bool DevMode { get; init; }
/// <summary>개발자 모드: 도구 실행 전 매번 사용자 승인 대기.</summary>
public bool DevModeStepApproval { get; init; }
/// <summary>권한 확인 콜백 (Ask 모드). 반환값: true=승인, false=거부.</summary>
public Func<string, string, Task<bool>>? AskPermission { get; init; }
/// <summary>사용자 의사결정 콜백. (질문, 선택지) → 사용자 응답 문자열.</summary>
public Func<string, List<string>, Task<string?>>? UserDecision { get; init; }
/// <summary>에이전트 질문 콜백 (UserAskTool 전용). (질문, 선택지, 기본값) → 사용자 응답.</summary>
public Func<string, List<string>, string, Task<string?>>? UserAskCallback { get; init; }
/// <summary>경로가 허용되는지 확인합니다.</summary>
public bool IsPathAllowed(string path)
{
var fullPath = Path.GetFullPath(path);
// 차단 확장자 검사
var ext = Path.GetExtension(fullPath).ToLowerInvariant();
if (BlockedExtensions.Any(e => string.Equals(e, ext, StringComparison.OrdinalIgnoreCase)))
return false;
// 차단 경로 패턴 검사
foreach (var pattern in BlockedPaths)
{
// 간단한 와일드카드 매칭: *\Windows\* → fullPath에 \Windows\ 포함 시 차단
var clean = pattern.Replace("*", "");
if (!string.IsNullOrEmpty(clean) && fullPath.Contains(clean, StringComparison.OrdinalIgnoreCase))
return false;
}
// 작업 폴더 제한: 작업 폴더가 설정되어 있으면 하위 경로만 허용
if (!string.IsNullOrEmpty(WorkFolder))
{
var workFull = Path.GetFullPath(WorkFolder);
if (!fullPath.StartsWith(workFull, StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
}
/// <summary>
/// 파일 경로에 타임스탬프를 추가합니다.
/// 예: report.html → report_20260328_1430.html
/// 동일 이름 파일이 이미 존재하면 자동으로 타임스탬프를 붙입니다.
/// </summary>
public static string EnsureTimestampedPath(string fullPath)
{
var dir = Path.GetDirectoryName(fullPath) ?? "";
var name = Path.GetFileNameWithoutExtension(fullPath);
var ext = Path.GetExtension(fullPath);
var stamp = DateTime.Now.ToString("yyyyMMdd_HHmm");
// 이미 타임스탬프가 포함된 파일명이면 그대로 사용
if (System.Text.RegularExpressions.Regex.IsMatch(name, @"_\d{8}_\d{4}$"))
return fullPath;
var timestamped = Path.Combine(dir, $"{name}_{stamp}{ext}");
return timestamped;
}
/// <summary>파일 쓰기/수정 권한을 확인합니다. 도구별 권한 오버라이드를 우선 적용합니다.</summary>
public async Task<bool> CheckWritePermissionAsync(string toolName, string filePath)
{
return await CheckToolPermissionAsync(toolName, filePath);
}
public string GetEffectiveToolPermission(string toolName)
{
if (ToolPermissions.TryGetValue(toolName, out var toolPerm) &&
!string.IsNullOrWhiteSpace(toolPerm))
return toolPerm;
if (ToolPermissions.TryGetValue("*", out var wildcardPerm) &&
!string.IsNullOrWhiteSpace(wildcardPerm))
return wildcardPerm;
if (ToolPermissions.TryGetValue("default", out var defaultPerm) &&
!string.IsNullOrWhiteSpace(defaultPerm))
return defaultPerm;
return SensitiveTools.Contains(toolName) ? Permission : "Auto";
}
public async Task<bool> CheckToolPermissionAsync(string toolName, string target)
{
if (AxCopilot.Services.OperationModePolicy.IsInternal(OperationMode)
&& AxCopilot.Services.OperationModePolicy.IsBlockedAgentToolInInternalMode(toolName, target))
return false;
var effectivePerm = GetEffectiveToolPermission(toolName);
if (string.Equals(effectivePerm, "Deny", StringComparison.OrdinalIgnoreCase)) return false;
if (string.Equals(effectivePerm, "Auto", StringComparison.OrdinalIgnoreCase)) return true;
if (AskPermission == null) return false;
var normalizedTarget = string.IsNullOrWhiteSpace(target) ? toolName : target.Trim();
var cacheKey = $"{toolName}|{normalizedTarget}";
lock (_permissionLock)
{
if (_approvedPermissionCache.Contains(cacheKey))
return true;
}
var allowed = await AskPermission(toolName, normalizedTarget);
if (allowed)
{
lock (_permissionLock)
_approvedPermissionCache.Add(cacheKey);
}
return allowed;
}
}
/// <summary>에이전트 이벤트 (UI 표시용).</summary>
public class AgentEvent
{
public DateTime Timestamp { get; init; } = DateTime.Now;
public string RunId { get; init; } = "";
public AgentEventType Type { get; init; }
public string ToolName { get; init; } = "";
public string Summary { get; init; } = "";
public string? FilePath { get; init; }
public bool Success { get; init; } = true;
/// <summary>Task Decomposition: 현재 단계 / 전체 단계 (진행률 표시용).</summary>
public int StepCurrent { get; init; }
public int StepTotal { get; init; }
/// <summary>Task Decomposition: 단계 목록.</summary>
public List<string>? Steps { get; init; }
// ── 워크플로우 분석기용 확장 필드 ──
/// <summary>도구 실행 소요 시간 (ms). 0이면 미측정.</summary>
public long ElapsedMs { get; init; }
/// <summary>이번 LLM 호출의 입력 토큰 수.</summary>
public int InputTokens { get; init; }
/// <summary>이번 LLM 호출의 출력 토큰 수.</summary>
public int OutputTokens { get; init; }
/// <summary>도구 파라미터 JSON (debug 모드에서만 기록).</summary>
public string? ToolInput { get; init; }
/// <summary>현재 에이전트 루프 반복 번호.</summary>
public int Iteration { get; init; }
}
public enum AgentEventType
{
Thinking, // LLM 사고 중
Planning, // 작업 계획 수립
StepStart, // 단계 시작
StepDone, // 단계 완료
HookResult, // 훅 실행 결과
PermissionRequest, // 권한 승인 대기
PermissionGranted, // 권한 승인됨
PermissionDenied, // 권한 거부/차단
ToolCall, // 도구 호출
ToolResult, // 도구 결과
SkillCall, // 스킬 호출
Error, // 오류
Complete, // 완료
Decision, // 사용자 의사결정 대기
Paused, // 에이전트 일시정지
Resumed, // 에이전트 재개
}

View File

@@ -0,0 +1,150 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 이미지를 분석하여 내용 설명, 텍스트 추출, 차트 데이터 해석을 수행하는 도구.
/// LLM 멀티모달 API를 활용합니다.
/// </summary>
public class ImageAnalyzeTool : IAgentTool
{
public string Name => "image_analyze";
public string Description =>
"Analyze an image using LLM multimodal vision. " +
"Tasks: describe (general description), extract_text (OCR-like text extraction), " +
"extract_data (extract structured data like tables/charts from image), " +
"compare (compare two images and describe differences).";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["image_path"] = new()
{
Type = "string",
Description = "Path to the image file (.png, .jpg, .jpeg, .bmp, .gif, .webp)."
},
["task"] = new()
{
Type = "string",
Description = "Analysis task: describe, extract_text, extract_data, compare. Default: describe",
Enum = ["describe", "extract_text", "extract_data", "compare"]
},
["compare_path"] = new()
{
Type = "string",
Description = "Path to second image for comparison (only used with task=compare)."
},
["question"] = new()
{
Type = "string",
Description = "Optional specific question about the image."
},
["language"] = new()
{
Type = "string",
Description = "Response language: ko (Korean), en (English). Default: ko"
},
},
Required = ["image_path"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var imagePath = args.GetProperty("image_path").GetString() ?? "";
var task = args.TryGetProperty("task", out var taskEl) ? taskEl.GetString() ?? "describe" : "describe";
var question = args.TryGetProperty("question", out var qEl) ? qEl.GetString() ?? "" : "";
var language = args.TryGetProperty("language", out var langEl) ? langEl.GetString() ?? "ko" : "ko";
var fullPath = FileReadTool.ResolvePath(imagePath, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
if (!File.Exists(fullPath))
return ToolResult.Fail($"파일 없음: {fullPath}");
// 지원 이미지 형식 확인
var ext = Path.GetExtension(fullPath).ToLowerInvariant();
if (!IsImageExtension(ext))
return ToolResult.Fail($"지원하지 않는 이미지 형식: {ext}");
// 이미지를 base64로 인코딩
var imageBytes = await File.ReadAllBytesAsync(fullPath, ct);
var base64 = Convert.ToBase64String(imageBytes);
var mimeType = ext switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".bmp" => "image/bmp",
_ => "image/png"
};
// 파일 크기 제한 (10MB)
if (imageBytes.Length > 10 * 1024 * 1024)
return ToolResult.Fail("이미지 크기가 10MB를 초과합니다.");
// 비교 모드: 두 번째 이미지
string? compareBase64 = null;
string? compareMime = null;
if (task == "compare" && args.TryGetProperty("compare_path", out var cpEl))
{
var comparePath = FileReadTool.ResolvePath(cpEl.GetString() ?? "", context.WorkFolder);
if (File.Exists(comparePath) && context.IsPathAllowed(comparePath))
{
var compareBytes = await File.ReadAllBytesAsync(comparePath, ct);
compareBase64 = Convert.ToBase64String(compareBytes);
var compareExt = Path.GetExtension(comparePath).ToLowerInvariant();
compareMime = compareExt switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/png"
};
}
}
// 프롬프트 구성
var langPrompt = language == "en" ? "Respond in English." : "한국어로 응답하세요.";
var prompt = task switch
{
"extract_text" =>
$"이 이미지에서 모든 텍스트를 추출하세요. 원본 레이아웃을 최대한 유지하세요. {langPrompt}",
"extract_data" =>
$"이 이미지에서 구조화된 데이터를 추출하세요. 테이블, 차트, 그래프 등의 데이터를 " +
$"CSV 또는 JSON 형식으로 변환하세요. 차트의 경우 각 항목의 값을 추정하세요. {langPrompt}",
"compare" =>
$"두 이미지를 비교하고 차이점을 설명하세요. {langPrompt}",
_ => string.IsNullOrEmpty(question)
? $"이 이미지의 내용을 상세하게 설명하세요. 주요 요소, 텍스트, 레이아웃, 색상 등을 포함하세요. {langPrompt}"
: $"{question} {langPrompt}"
};
// LLM에 이미지 분석 요청을 위한 결과 생성
// 실제 LLM 호출은 에이전트 루프에서 수행하므로, 여기서는 이미지 정보와 프롬프트를 반환
var info = new System.Text.StringBuilder();
info.AppendLine($"🖼 이미지 분석 준비 완료");
info.AppendLine($" 파일: {Path.GetFileName(fullPath)}");
info.AppendLine($" 크기: {imageBytes.Length / 1024}KB");
info.AppendLine($" 형식: {mimeType}");
info.AppendLine($" 작업: {task}");
info.AppendLine();
info.AppendLine($"[IMAGE_BASE64:{mimeType}]{base64}[/IMAGE_BASE64]");
if (compareBase64 != null)
info.AppendLine($"[IMAGE_BASE64:{compareMime}]{compareBase64}[/IMAGE_BASE64]");
info.AppendLine();
info.AppendLine($"분석 프롬프트: {prompt}");
return ToolResult.Ok(info.ToString());
}
private static bool IsImageExtension(string ext)
{
return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".webp";
}
}

View File

@@ -0,0 +1,231 @@
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// JSON 파싱·변환·검증·포맷팅 도구.
/// jq 스타일 경로 쿼리, 유효성 검사, 포맷 변환을 지원합니다.
/// </summary>
public class JsonTool : IAgentTool
{
public string Name => "json_tool";
public string Description =>
"JSON processing tool. Actions: " +
"'validate' — check if text is valid JSON and report errors; " +
"'format' — pretty-print or minify JSON; " +
"'query' — extract value by dot-path (e.g. 'data.users[0].name'); " +
"'keys' — list top-level keys; " +
"'convert' — convert between JSON/CSV (flat arrays only).";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "Action to perform",
Enum = ["validate", "format", "query", "keys", "convert"],
},
["json"] = new()
{
Type = "string",
Description = "JSON text to process",
},
["path"] = new()
{
Type = "string",
Description = "Dot-path for query action (e.g. 'data.items[0].name')",
},
["minify"] = new()
{
Type = "string",
Description = "For format action: 'true' to minify, 'false' to pretty-print (default)",
},
["target_format"] = new()
{
Type = "string",
Description = "For convert action: target format",
Enum = ["csv"],
},
},
Required = ["action", "json"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var json = args.GetProperty("json").GetString() ?? "";
try
{
return Task.FromResult(action switch
{
"validate" => Validate(json),
"format" => Format(json, args.TryGetProperty("minify", out var m) && m.GetString() == "true"),
"query" => Query(json, args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : ""),
"keys" => Keys(json),
"convert" => Convert(json, args.TryGetProperty("target_format", out var tf) ? tf.GetString() ?? "csv" : "csv"),
_ => ToolResult.Fail($"Unknown action: {action}"),
});
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"JSON 처리 오류: {ex.Message}"));
}
}
private static ToolResult Validate(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var kind = root.ValueKind switch
{
JsonValueKind.Object => $"Object ({root.EnumerateObject().Count()} keys)",
JsonValueKind.Array => $"Array ({root.GetArrayLength()} items)",
_ => root.ValueKind.ToString(),
};
return ToolResult.Ok($"✓ Valid JSON — {kind}");
}
catch (JsonException ex)
{
return ToolResult.Ok($"✗ Invalid JSON — {ex.Message}");
}
}
private static ToolResult Format(string json, bool minify)
{
using var doc = JsonDocument.Parse(json);
var opts = new JsonSerializerOptions { WriteIndented = !minify };
var result = JsonSerializer.Serialize(doc.RootElement, opts);
if (result.Length > 8000) result = result[..8000] + "\n... (truncated)";
return ToolResult.Ok(result);
}
private static ToolResult Query(string json, string path)
{
if (string.IsNullOrEmpty(path))
return ToolResult.Fail("path parameter is required for query action");
using var doc = JsonDocument.Parse(json);
var current = doc.RootElement;
foreach (var segment in ParsePath(path))
{
if (segment.IsIndex)
{
if (current.ValueKind != JsonValueKind.Array || segment.Index >= current.GetArrayLength())
return ToolResult.Fail($"Array index [{segment.Index}] out of range");
current = current[segment.Index];
}
else
{
if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment.Key, out var prop))
return ToolResult.Fail($"Key '{segment.Key}' not found");
current = prop;
}
}
var value = current.ValueKind switch
{
JsonValueKind.String => current.GetString() ?? "",
JsonValueKind.Number => current.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => "null",
_ => JsonSerializer.Serialize(current, new JsonSerializerOptions { WriteIndented = true }),
};
if (value.Length > 5000) value = value[..5000] + "\n... (truncated)";
return ToolResult.Ok(value);
}
private static ToolResult Keys(string json)
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return ToolResult.Fail("Root element is not an object");
var keys = doc.RootElement.EnumerateObject().Select(p =>
{
var type = p.Value.ValueKind switch
{
JsonValueKind.Object => "object",
JsonValueKind.Array => $"array[{p.Value.GetArrayLength()}]",
JsonValueKind.String => "string",
JsonValueKind.Number => "number",
JsonValueKind.True or JsonValueKind.False => "boolean",
_ => "null",
};
return $" {p.Name}: {type}";
});
return ToolResult.Ok($"Keys ({doc.RootElement.EnumerateObject().Count()}):\n{string.Join("\n", keys)}");
}
private static ToolResult Convert(string json, string targetFormat)
{
if (targetFormat != "csv")
return ToolResult.Fail($"Unsupported target format: {targetFormat}");
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("JSON must be an array for CSV conversion");
var arr = doc.RootElement;
if (arr.GetArrayLength() == 0)
return ToolResult.Ok("(empty array)");
// 모든 키 수집
var allKeys = new List<string>();
foreach (var item in arr.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object)
return ToolResult.Fail("All array items must be objects for CSV conversion");
foreach (var prop in item.EnumerateObject())
if (!allKeys.Contains(prop.Name)) allKeys.Add(prop.Name);
}
var sb = new System.Text.StringBuilder();
sb.AppendLine(string.Join(",", allKeys.Select(k => $"\"{k}\"")));
foreach (var item in arr.EnumerateArray())
{
var values = allKeys.Select(k =>
{
if (!item.TryGetProperty(k, out var v)) return "\"\"";
return v.ValueKind == JsonValueKind.String
? $"\"{v.GetString()?.Replace("\"", "\"\"") ?? ""}\""
: v.GetRawText();
});
sb.AppendLine(string.Join(",", values));
}
var result = sb.ToString();
if (result.Length > 8000) result = result[..8000] + "\n... (truncated)";
return ToolResult.Ok(result);
}
private record PathSegment(string Key, int Index, bool IsIndex);
private static List<PathSegment> ParsePath(string path)
{
var segments = new List<PathSegment>();
foreach (var part in path.Split('.'))
{
var bracketIdx = part.IndexOf('[');
if (bracketIdx >= 0)
{
var key = part[..bracketIdx];
if (!string.IsNullOrEmpty(key))
segments.Add(new PathSegment(key, 0, false));
var idxStr = part[(bracketIdx + 1)..].TrimEnd(']');
if (int.TryParse(idxStr, out var idx))
segments.Add(new PathSegment("", idx, true));
}
else
{
segments.Add(new PathSegment(part, 0, false));
}
}
return segments;
}
}

View File

@@ -0,0 +1,202 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// LSP 기반 코드 인텔리전스 도구.
/// 정의 이동(goto_definition), 참조 검색(find_references), 심볼 목록(symbols) 3가지 액션을 제공합니다.
/// </summary>
public class LspTool : IAgentTool, IDisposable
{
public string Name => "lsp_code_intel";
public string Description =>
"코드 인텔리전스 도구. 정의 이동, 참조 검색, 심볼 목록을 제공합니다.\n" +
"- action=\"goto_definition\": 심볼의 정의 위치를 찾습니다 (파일, 라인, 컬럼)\n" +
"- action=\"find_references\": 심볼이 사용된 모든 위치를 찾습니다\n" +
"- action=\"symbols\": 파일 내 모든 심볼(클래스, 메서드, 필드 등)을 나열합니다\n" +
"file_path, line, character 파라미터가 필요합니다 (line과 character는 0-based).";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new ToolProperty
{
Type = "string",
Description = "수행할 작업: goto_definition | find_references | symbols",
Enum = new() { "goto_definition", "find_references", "symbols" }
},
["file_path"] = new ToolProperty
{
Type = "string",
Description = "대상 파일 경로 (절대 또는 작업 폴더 기준 상대 경로)"
},
["line"] = new ToolProperty
{
Type = "integer",
Description = "대상 라인 번호 (0-based). symbols 액션에서는 불필요."
},
["character"] = new ToolProperty
{
Type = "integer",
Description = "라인 내 문자 위치 (0-based). symbols 액션에서는 불필요."
},
},
Required = new() { "action", "file_path" }
};
// 언어별 LSP 클라이언트 캐시
private readonly Dictionary<string, LspClientService> _clients = new();
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
// 설정 체크
var app = System.Windows.Application.Current as App;
if (!(app?.SettingsService?.Settings.Llm.Code.EnableLsp ?? true))
return ToolResult.Ok("LSP 코드 인텔리전스가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
var filePath = args.TryGetProperty("file_path", out var f) ? f.GetString() ?? "" : "";
var line = args.TryGetProperty("line", out var l) ? l.GetInt32() : 0;
var character = args.TryGetProperty("character", out var ch) ? ch.GetInt32() : 0;
if (string.IsNullOrEmpty(filePath))
return ToolResult.Fail("file_path가 필요합니다.");
// 절대 경로 변환
if (!Path.IsPathRooted(filePath) && !string.IsNullOrEmpty(context.WorkFolder))
filePath = Path.Combine(context.WorkFolder, filePath);
if (!File.Exists(filePath))
return ToolResult.Fail($"파일을 찾을 수 없습니다: {filePath}");
// 언어 감지
var language = DetectLanguage(filePath);
if (language == null)
return ToolResult.Fail($"지원하지 않는 파일 형식: {Path.GetExtension(filePath)}");
// LSP 클라이언트 시작 (캐시)
var client = await GetOrCreateClientAsync(language, context.WorkFolder, ct);
if (client == null || !client.IsConnected)
return ToolResult.Fail($"{language} 언어 서버를 시작할 수 없습니다. 해당 언어 서버가 설치되어 있는지 확인하세요.");
try
{
return action switch
{
"goto_definition" => await GotoDefinitionAsync(client, filePath, line, character, ct),
"find_references" => await FindReferencesAsync(client, filePath, line, character, ct),
"symbols" => await GetSymbolsAsync(client, filePath, ct),
_ => ToolResult.Fail($"알 수 없는 액션: {action}. goto_definition | find_references | symbols 중 선택하세요.")
};
}
catch (Exception ex)
{
return ToolResult.Fail($"LSP 오류: {ex.Message}");
}
}
private async Task<ToolResult> GotoDefinitionAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
{
var loc = await client.GotoDefinitionAsync(filePath, line, character, ct);
if (loc == null)
return ToolResult.Ok("정의를 찾을 수 없습니다 (해당 위치에 심볼이 없거나 외부 라이브러리일 수 있습니다).");
// 정의 위치의 코드를 읽어서 컨텍스트 제공
var contextCode = ReadCodeContext(loc.FilePath, loc.Line, 3);
return ToolResult.Ok(
$"정의 위치: {loc}\n\n```\n{contextCode}\n```",
loc.FilePath);
}
private async Task<ToolResult> FindReferencesAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
{
var locations = await client.FindReferencesAsync(filePath, line, character, ct);
if (locations.Count == 0)
return ToolResult.Ok("참조를 찾을 수 없습니다.");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"총 {locations.Count}개 참조:");
foreach (var loc in locations.Take(30))
sb.AppendLine($" {loc}");
if (locations.Count > 30)
sb.AppendLine($" ... 외 {locations.Count - 30}개");
return ToolResult.Ok(sb.ToString());
}
private async Task<ToolResult> GetSymbolsAsync(LspClientService client, string filePath, CancellationToken ct)
{
var symbols = await client.GetDocumentSymbolsAsync(filePath, ct);
if (symbols.Count == 0)
return ToolResult.Ok("심볼을 찾을 수 없습니다.");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"총 {symbols.Count}개 심볼:");
foreach (var sym in symbols)
sb.AppendLine($" {sym}");
return ToolResult.Ok(sb.ToString());
}
private async Task<LspClientService?> GetOrCreateClientAsync(string language, string workFolder, CancellationToken ct)
{
if (_clients.TryGetValue(language, out var existing) && existing.IsConnected)
return existing;
var client = new LspClientService(language);
var started = await client.StartAsync(workFolder, ct);
if (!started)
{
client.Dispose();
return null;
}
_clients[language] = client;
return client;
}
private static string? DetectLanguage(string filePath)
{
var ext = Path.GetExtension(filePath).ToLowerInvariant();
return ext switch
{
".cs" => "csharp",
".ts" or ".tsx" => "typescript",
".js" or ".jsx" => "javascript",
".py" => "python",
".cpp" or ".cc" or ".cxx" or ".c" or ".h" or ".hpp" => "cpp",
".java" => "java",
_ => null
};
}
public void Dispose()
{
foreach (var client in _clients.Values)
client.Dispose();
_clients.Clear();
}
private static string ReadCodeContext(string filePath, int targetLine, int contextLines)
{
try
{
var read = TextFileCodec.ReadAllText(filePath);
var lines = TextFileCodec.SplitLines(read.Text);
var start = Math.Max(0, targetLine - contextLines);
var end = Math.Min(lines.Length - 1, targetLine + contextLines);
var sb = new System.Text.StringBuilder();
for (int i = start; i <= end; i++)
{
var marker = i == targetLine ? ">>>" : " ";
sb.AppendLine($"{marker} {i + 1,4}: {lines[i].TrimEnd('\r')}");
}
return sb.ToString().TrimEnd();
}
catch { return "(코드를 읽을 수 없습니다)"; }
}
}

View File

@@ -0,0 +1,69 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Markdown (.md) 문서를 생성하는 내장 스킬.
/// LLM이 마크다운 내용을 전달하면 파일로 저장합니다.
/// </summary>
public class MarkdownSkill : IAgentTool
{
public string Name => "markdown_create";
public string Description => "Create a Markdown (.md) document file. Provide the content in Markdown format.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "Output file path (.md). Relative to work folder." },
["content"] = new() { Type = "string", Description = "Markdown content to write" },
["title"] = new() { Type = "string", Description = "Optional document title. If provided, prepends '# title' at the top." },
},
Required = ["path", "content"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var content = args.GetProperty("content").GetString() ?? "";
var title = args.TryGetProperty("title", out var t) ? t.GetString() : null;
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
if (!fullPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
fullPath += ".md";
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
try
{
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var sb = new StringBuilder();
if (!string.IsNullOrEmpty(title))
{
sb.AppendLine($"# {title}");
sb.AppendLine();
}
sb.Append(content);
await File.WriteAllTextAsync(fullPath, sb.ToString(), Encoding.UTF8, ct);
var lines = sb.ToString().Split('\n').Length;
return ToolResult.Ok(
$"Markdown 문서 저장 완료: {fullPath} ({lines} lines)",
fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"Markdown 저장 실패: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,66 @@
using System.Data;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>수학 수식을 계산하는 도구.</summary>
public class MathTool : IAgentTool
{
public string Name => "math_eval";
public string Description =>
"Evaluate a mathematical expression and return the result. " +
"Supports: +, -, *, /, %, parentheses, and common math operations. " +
"Use for calculations, unit conversions, and numeric analysis.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["expression"] = new()
{
Type = "string",
Description = "Mathematical expression to evaluate (e.g. '(100 * 1.08) / 3')",
},
["precision"] = new()
{
Type = "integer",
Description = "Decimal places for rounding (default: 6)",
},
},
Required = ["expression"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var expression = args.GetProperty("expression").GetString() ?? "";
var precision = args.TryGetProperty("precision", out var p) ? p.GetInt32() : 6;
if (string.IsNullOrWhiteSpace(expression))
return Task.FromResult(ToolResult.Fail("수식이 비어 있습니다."));
try
{
// DataTable.Compute를 사용한 안전한 수식 평가
var sanitized = expression
.Replace("^", " ") // XOR 방지 — ** 패턴은 아래에서 처리
.Replace("Math.", "")
.Replace("System.", "");
// 기본 보안 검사: 알파벳 함수 호출 차단
if (System.Text.RegularExpressions.Regex.IsMatch(sanitized, @"[a-zA-Z]{3,}"))
return Task.FromResult(ToolResult.Fail("함수 호출은 지원하지 않습니다. 기본 사칙연산만 가능합니다."));
var dt = new DataTable();
var result = dt.Compute(sanitized, null);
var value = Convert.ToDouble(result);
var rounded = Math.Round(value, precision);
return Task.FromResult(ToolResult.Ok(
$"Expression: {expression}\nResult: {rounded}"));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"수식 평가 오류: {ex.Message}"));
}
}
}

View File

@@ -0,0 +1,61 @@
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>MCP 서버의 리소스 목록을 조회합니다.</summary>
public class McpListResourcesTool : IAgentTool
{
private readonly Func<IReadOnlyCollection<McpClientService>> _getClients;
public McpListResourcesTool(Func<IReadOnlyCollection<McpClientService>> getClients)
{
_getClients = getClients;
}
public string Name => "mcp_list_resources";
public string Description => "연결된 MCP 서버들의 리소스 목록을 조회합니다. server_name을 지정하면 특정 서버만 조회합니다.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["server_name"] = new ToolProperty
{
Type = "string",
Description = "조회할 MCP 서버 이름. 생략하면 모든 연결된 서버를 조회합니다."
}
}
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var serverName = args.ValueKind == JsonValueKind.Object &&
args.TryGetProperty("server_name", out var serverProp)
? serverProp.GetString() ?? ""
: "";
var clients = _getClients()
.Where(c => c.IsConnected &&
(string.IsNullOrWhiteSpace(serverName) || string.Equals(c.ServerName, serverName, StringComparison.OrdinalIgnoreCase)))
.ToList();
if (clients.Count == 0)
return ToolResult.Fail(string.IsNullOrWhiteSpace(serverName)
? "연결된 MCP 서버가 없습니다."
: $"MCP 서버 '{serverName}'를 찾지 못했습니다.");
var lines = new List<string>();
foreach (var client in clients)
{
var resources = await client.ListResourcesAsync(ct);
lines.Add($"[{client.ServerName}] {resources.Count}개 리소스");
foreach (var resource in resources.Take(20))
{
var desc = string.IsNullOrWhiteSpace(resource.Description) ? "" : $" - {resource.Description}";
var mime = string.IsNullOrWhiteSpace(resource.MimeType) ? "" : $" ({resource.MimeType})";
lines.Add($"- {resource.Name} :: {resource.Uri}{mime}{desc}");
}
}
return ToolResult.Ok(string.Join(Environment.NewLine, lines));
}
}

View File

@@ -0,0 +1,69 @@
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>MCP 리소스 내용을 읽습니다.</summary>
public class McpReadResourceTool : IAgentTool
{
private readonly Func<IReadOnlyCollection<McpClientService>> _getClients;
public McpReadResourceTool(Func<IReadOnlyCollection<McpClientService>> getClients)
{
_getClients = getClients;
}
public string Name => "mcp_read_resource";
public string Description => "MCP 리소스 URI를 읽어 내용을 가져옵니다. server_name을 지정하면 해당 서버에서 우선 조회합니다.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["uri"] = new ToolProperty
{
Type = "string",
Description = "읽을 MCP 리소스 URI"
},
["server_name"] = new ToolProperty
{
Type = "string",
Description = "리소스가 있는 MCP 서버 이름. 생략하면 연결된 서버들에서 검색합니다."
}
},
Required = new() { "uri" }
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (args.ValueKind != JsonValueKind.Object ||
!args.TryGetProperty("uri", out var uriProp) ||
string.IsNullOrWhiteSpace(uriProp.GetString()))
return ToolResult.Fail("uri가 필요합니다.");
var uri = uriProp.GetString()!;
var serverName = args.TryGetProperty("server_name", out var serverProp)
? serverProp.GetString() ?? ""
: "";
var clients = _getClients()
.Where(c => c.IsConnected &&
(string.IsNullOrWhiteSpace(serverName) || string.Equals(c.ServerName, serverName, StringComparison.OrdinalIgnoreCase)))
.ToList();
if (clients.Count == 0)
return ToolResult.Fail(string.IsNullOrWhiteSpace(serverName)
? "연결된 MCP 서버가 없습니다."
: $"MCP 서버 '{serverName}'를 찾지 못했습니다.");
foreach (var client in clients)
{
var resources = await client.ListResourcesAsync(ct);
if (resources.Any(r => string.Equals(r.Uri, uri, StringComparison.OrdinalIgnoreCase)))
{
var content = await client.ReadResourceAsync(uri, ct);
return ToolResult.Ok($"[{client.ServerName}] {uri}{Environment.NewLine}{content}");
}
}
return ToolResult.Fail($"리소스 '{uri}'를 찾지 못했습니다.");
}
}

View File

@@ -0,0 +1,77 @@
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// MCP 서버의 도구를 에이전트 도구로 래핑합니다.
/// 하나의 McpTool 인스턴스가 하나의 MCP 도구를 나타냅니다.
/// </summary>
public class McpTool : IAgentTool
{
private readonly McpClientService _client;
private readonly Models.McpToolDefinition _def;
public McpTool(McpClientService client, Models.McpToolDefinition def)
{
_client = client;
_def = def;
}
public string Name => $"mcp_{_def.ServerName}_{_def.Name}";
public string Description => $"[MCP:{_def.ServerName}] {_def.Description}";
public ToolParameterSchema Parameters
{
get
{
var schema = new ToolParameterSchema
{
Properties = new(),
Required = new(),
};
foreach (var (name, param) in _def.Parameters)
{
schema.Properties[name] = new ToolProperty
{
Type = param.Type,
Description = param.Description,
};
if (param.Required) schema.Required.Add(name);
}
return schema;
}
}
public async Task<ToolResult> ExecuteAsync(
JsonElement args, AgentContext context, CancellationToken ct = default)
{
try
{
if (!_client.IsConnected)
return ToolResult.Fail($"MCP 서버 '{_def.ServerName}'에 연결되어 있지 않습니다.");
var arguments = new Dictionary<string, object>();
if (args.ValueKind == JsonValueKind.Object)
{
foreach (var prop in args.EnumerateObject())
{
arguments[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.String => prop.Value.GetString()!,
JsonValueKind.Number => prop.Value.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => prop.Value.ToString(),
};
}
}
var result = await _client.CallToolAsync(_def.Name, arguments, ct);
return ToolResult.Ok(result);
}
catch (Exception ex)
{
return ToolResult.Fail($"MCP 도구 실행 실패: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,122 @@
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 에이전트 메모리 관리 도구.
/// 프로젝트 규칙, 사용자 선호도, 학습 내용을 저장/검색/삭제합니다.
/// </summary>
public class MemoryTool : IAgentTool
{
public string Name => "memory";
public string Description =>
"프로젝트 규칙, 사용자 선호도, 학습 내용을 저장하고 검색합니다.\n" +
"대화 간 지속되는 메모리로, 새 대화에서도 이전에 학습한 내용을 활용할 수 있습니다.\n" +
"- action=\"save\": 새 메모리 저장 (type, content 필수)\n" +
"- action=\"search\": 관련 메모리 검색 (query 필수)\n" +
"- action=\"list\": 현재 메모리 전체 목록\n" +
"- action=\"delete\": 메모리 삭제 (id 필수)\n" +
"type 종류: rule(프로젝트 규칙), preference(사용자 선호), fact(사실), correction(실수 교정)";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new() { Type = "string", Description = "save | search | list | delete" },
["type"] = new() { Type = "string", Description = "메모리 유형: rule | preference | fact | correction. save 시 필수." },
["content"] = new() { Type = "string", Description = "저장할 내용. save 시 필수." },
["query"] = new() { Type = "string", Description = "검색 쿼리. search 시 필수." },
["id"] = new() { Type = "string", Description = "메모리 ID. delete 시 필수." },
},
Required = ["action"]
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
// 설정 체크
var app = System.Windows.Application.Current as App;
if (!(app?.SettingsService?.Settings.Llm.EnableAgentMemory ?? true))
return Task.FromResult(ToolResult.Ok("에이전트 메모리가 비활성 상태입니다. 설정에서 활성화하세요."));
var memoryService = app?.MemoryService;
if (memoryService == null)
return Task.FromResult(ToolResult.Fail("메모리 서비스를 사용할 수 없습니다."));
if (!args.TryGetProperty("action", out var actionEl))
return Task.FromResult(ToolResult.Fail("action이 필요합니다."));
var action = actionEl.GetString() ?? "";
return Task.FromResult(action switch
{
"save" => ExecuteSave(args, memoryService, context),
"search" => ExecuteSearch(args, memoryService),
"list" => ExecuteList(memoryService),
"delete" => ExecuteDelete(args, memoryService),
_ => ToolResult.Fail($"알 수 없는 액션: {action}. save | search | list | delete 중 선택하세요."),
});
}
private static ToolResult ExecuteSave(JsonElement args, AgentMemoryService svc, AgentContext context)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString() ?? "fact" : "fact";
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(content))
return ToolResult.Fail("content가 필요합니다.");
var validTypes = new[] { "rule", "preference", "fact", "correction" };
if (!validTypes.Contains(type))
return ToolResult.Fail($"잘못된 type: {type}. rule | preference | fact | correction 중 선택하세요.");
var workFolder = string.IsNullOrEmpty(context.WorkFolder) ? null : context.WorkFolder;
var entry = svc.Add(type, content, $"agent:{context.ActiveTab}", workFolder);
return ToolResult.Ok($"메모리 저장됨 [{entry.Type}] (ID: {entry.Id}): {entry.Content}");
}
private static ToolResult ExecuteSearch(JsonElement args, AgentMemoryService svc)
{
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(query))
return ToolResult.Fail("query가 필요합니다.");
var results = svc.GetRelevant(query, 10);
if (results.Count == 0)
return ToolResult.Ok("관련 메모리가 없습니다.");
var sb = new StringBuilder();
sb.AppendLine($"관련 메모리 {results.Count}개:");
foreach (var e in results)
sb.AppendLine($" [{e.Type}] {e.Content} (사용 {e.UseCount}회, ID: {e.Id})");
return ToolResult.Ok(sb.ToString());
}
private static ToolResult ExecuteList(AgentMemoryService svc)
{
var all = svc.All;
if (all.Count == 0)
return ToolResult.Ok("저장된 메모리가 없습니다.");
var sb = new StringBuilder();
sb.AppendLine($"전체 메모리 {all.Count}개:");
foreach (var group in all.GroupBy(e => e.Type))
{
sb.AppendLine($"\n[{group.Key}]");
foreach (var e in group.OrderByDescending(e => e.UseCount))
sb.AppendLine($" • {e.Content} (사용 {e.UseCount}회, ID: {e.Id})");
}
return ToolResult.Ok(sb.ToString());
}
private static ToolResult ExecuteDelete(JsonElement args, AgentMemoryService svc)
{
var id = args.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(id))
return ToolResult.Fail("id가 필요합니다.");
return svc.Remove(id)
? ToolResult.Ok($"메모리 삭제됨 (ID: {id})")
: ToolResult.Fail($"해당 ID의 메모리를 찾을 수 없습니다: {id}");
}
}

View File

@@ -0,0 +1,94 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>여러 파일을 한 번에 읽어 결합 반환하는 도구.</summary>
public class MultiReadTool : IAgentTool
{
public string Name => "multi_read";
public string Description =>
"Read multiple files in a single call (max 10). " +
"Returns concatenated contents with file headers. " +
"More efficient than calling file_read multiple times.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["paths"] = new()
{
Type = "array",
Description = "List of file paths to read (max 10)",
Items = new() { Type = "string", Description = "File path" },
},
["max_lines"] = new()
{
Type = "integer",
Description = "Max lines per file (default 200)",
},
},
Required = ["paths"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var maxLines = args.TryGetProperty("max_lines", out var ml) ? ml.GetInt32() : 200;
if (maxLines <= 0) maxLines = 200;
if (!args.TryGetProperty("paths", out var pathsEl) || pathsEl.ValueKind != JsonValueKind.Array)
return Task.FromResult(ToolResult.Fail("'paths'는 문자열 배열이어야 합니다."));
var paths = new List<string>();
foreach (var p in pathsEl.EnumerateArray())
{
var s = p.GetString();
if (!string.IsNullOrEmpty(s)) paths.Add(s);
}
if (paths.Count == 0)
return Task.FromResult(ToolResult.Fail("읽을 파일이 없습니다."));
if (paths.Count > 10)
return Task.FromResult(ToolResult.Fail("최대 10개 파일만 지원합니다."));
var sb = new StringBuilder();
var readCount = 0;
foreach (var rawPath in paths)
{
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
sb.AppendLine($"═══ {Path.GetFileName(path)} ═══");
sb.AppendLine($"Path: {path}");
if (!context.IsPathAllowed(path))
{
sb.AppendLine("[접근 차단됨]");
}
else if (!File.Exists(path))
{
sb.AppendLine("[파일 없음]");
}
else
{
try
{
var lines = File.ReadLines(path).Take(maxLines).ToList();
for (var i = 0; i < lines.Count; i++)
sb.AppendLine($"{i + 1}\t{lines[i]}");
if (lines.Count >= maxLines)
sb.AppendLine($"... (이후 생략, max_lines={maxLines})");
readCount++;
}
catch (Exception ex)
{
sb.AppendLine($"[읽기 오류: {ex.Message}]");
}
}
sb.AppendLine();
}
return Task.FromResult(ToolResult.Ok($"{readCount}/{paths.Count}개 파일 읽기 완료.\n\n{sb}"));
}
}

View File

@@ -0,0 +1,152 @@
using System.Text.Json;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Threading;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Windows 알림 전송 도구.
/// 장시간 작업 완료 알림, 사용자 확인 요청 등을 트레이 또는 앱 내 토스트로 표시합니다.
/// </summary>
public class NotifyTool : IAgentTool
{
public string Name => "notify_tool";
public string Description =>
"Send a notification to the user. Use this when: " +
"a long-running task completes, an important result needs attention, " +
"or you want to inform the user of something. " +
"The notification appears as an in-app toast message.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["title"] = new()
{
Type = "string",
Description = "Notification title (short, 1-2 words)",
},
["message"] = new()
{
Type = "string",
Description = "Notification message (detail text)",
},
["level"] = new()
{
Type = "string",
Description = "Notification level: info (default), success, warning, error",
Enum = ["info", "success", "warning", "error"],
},
},
Required = ["title", "message"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var title = args.GetProperty("title").GetString() ?? "알림";
var message = args.GetProperty("message").GetString() ?? "";
var level = args.TryGetProperty("level", out var lv) ? lv.GetString() ?? "info" : "info";
try
{
Application.Current.Dispatcher.Invoke(() =>
{
ShowToast(title, message, level);
});
return Task.FromResult(ToolResult.Ok($"✓ Notification sent: [{level}] {title}"));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"알림 전송 실패: {ex.Message}"));
}
}
private static void ShowToast(string title, string message, string level)
{
var mainWindow = Application.Current.MainWindow;
if (mainWindow == null) return;
var (iconChar, iconColor) = level switch
{
"success" => ("\uE73E", "#34D399"),
"warning" => ("\uE7BA", "#F59E0B"),
"error" => ("\uEA39", "#F87171"),
_ => ("\uE946", "#4B5EFC"), // info
};
var toast = new Border
{
Background = new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E)),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(16, 12, 16, 12),
Margin = new Thickness(0, 0, 20, 20),
MinWidth = 280,
MaxWidth = 400,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Bottom,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 16,
ShadowDepth = 4,
Opacity = 0.4,
Color = Colors.Black,
},
};
var content = new StackPanel();
// 타이틀 행
var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 4) };
titleRow.Children.Add(new TextBlock
{
Text = iconChar,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 14,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(iconColor)),
Margin = new Thickness(0, 0, 8, 0),
VerticalAlignment = VerticalAlignment.Center,
});
titleRow.Children.Add(new TextBlock
{
Text = title,
FontSize = 13,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.White,
VerticalAlignment = VerticalAlignment.Center,
});
content.Children.Add(titleRow);
// 메시지
if (!string.IsNullOrEmpty(message))
{
content.Children.Add(new TextBlock
{
Text = message.Length > 200 ? message[..200] + "..." : message,
FontSize = 12,
Foreground = new SolidColorBrush(Color.FromRgb(0xAA, 0xAA, 0xCC)),
TextWrapping = TextWrapping.Wrap,
});
}
toast.Child = content;
// 기존 Grid/Panel에 추가
if (mainWindow.Content is Grid grid)
{
Grid.SetRowSpan(toast, grid.RowDefinitions.Count > 0 ? grid.RowDefinitions.Count : 1);
Grid.SetColumnSpan(toast, grid.ColumnDefinitions.Count > 0 ? grid.ColumnDefinitions.Count : 1);
grid.Children.Add(toast);
// 5초 후 자동 제거 (페이드 아웃)
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
timer.Tick += (_, _) =>
{
timer.Stop();
grid.Children.Remove(toast);
};
timer.Start();
}
}
}

View File

@@ -0,0 +1,70 @@
using System.Diagnostics;
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>파일/URL을 시스템 기본 앱으로 여는 도구.</summary>
public class OpenExternalTool : IAgentTool
{
public string Name => "open_external";
public string Description =>
"Open a file with its default application or open a URL in the default browser. " +
"Also supports opening a folder in File Explorer. " +
"Use after creating documents, reports, or charts for the user to view.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new()
{
Type = "string",
Description = "File path, directory path, or URL to open",
},
},
Required = ["path"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var rawPath = args.GetProperty("path").GetString() ?? "";
if (string.IsNullOrWhiteSpace(rawPath))
return Task.FromResult(ToolResult.Fail("경로가 비어 있습니다."));
try
{
// URL인 경우
if (rawPath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
rawPath.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
Process.Start(new ProcessStartInfo(rawPath) { UseShellExecute = true });
return Task.FromResult(ToolResult.Ok($"URL 열기: {rawPath}"));
}
// 파일/폴더 경로
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
if (!context.IsPathAllowed(path))
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {path}"));
if (File.Exists(path))
{
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
return Task.FromResult(ToolResult.Ok($"파일 열기: {path}", filePath: path));
}
if (Directory.Exists(path))
{
Process.Start(new ProcessStartInfo("explorer.exe", path));
return Task.FromResult(ToolResult.Ok($"폴더 열기: {path}", filePath: path));
}
return Task.FromResult(ToolResult.Fail($"경로를 찾을 수 없습니다: {path}"));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"열기 오류: {ex.Message}"));
}
}
}

View File

@@ -0,0 +1,324 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 성공적인 실행 흐름을 재사용 가능한 플레이북으로 저장/관리하는 도구.
/// .ax/playbooks/ 에 JSON 파일로 저장됩니다.
/// </summary>
public class PlaybookTool : IAgentTool
{
public string Name => "playbook";
public string Description =>
"Save, list, describe, or delete execution playbooks. " +
"A playbook captures a successful task workflow for reuse.\n" +
"- action=\"save\": Save a new playbook (name, description, steps required)\n" +
"- action=\"list\": List all saved playbooks\n" +
"- action=\"describe\": Show full playbook details (id required)\n" +
"- action=\"delete\": Delete a playbook (id required)";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "save | list | describe | delete",
Enum = ["save", "list", "describe", "delete"],
},
["name"] = new()
{
Type = "string",
Description = "Playbook name (for save)",
},
["description"] = new()
{
Type = "string",
Description = "What this playbook does (for save)",
},
["steps"] = new()
{
Type = "array",
Description = "List of step descriptions (for save)",
Items = new() { Type = "string", Description = "Step description" },
},
["tools_used"] = new()
{
Type = "array",
Description = "List of tool names used in this workflow (for save)",
Items = new() { Type = "string", Description = "Tool name" },
},
["id"] = new()
{
Type = "integer",
Description = "Playbook ID (for describe/delete)",
},
},
Required = ["action"],
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
if (!args.TryGetProperty("action", out var actionEl))
return ToolResult.Fail("action이 필요합니다.");
var action = actionEl.GetString() ?? "";
if (string.IsNullOrEmpty(context.WorkFolder))
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
var playbookDir = Path.Combine(context.WorkFolder, ".ax", "playbooks");
return action switch
{
"save" => await SavePlaybook(args, playbookDir, ct),
"list" => ListPlaybooks(playbookDir),
"describe" => await DescribePlaybook(args, playbookDir, ct),
"delete" => DeletePlaybook(args, playbookDir),
_ => ToolResult.Fail($"알 수 없는 액션: {action}. save | list | describe | delete 중 선택하세요."),
};
}
private static async Task<ToolResult> SavePlaybook(JsonElement args, string playbookDir, CancellationToken ct)
{
var name = args.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
var description = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(name))
return ToolResult.Fail("플레이북 name이 필요합니다.");
if (string.IsNullOrWhiteSpace(description))
return ToolResult.Fail("플레이북 description이 필요합니다.");
// steps 파싱
var steps = new List<string>();
if (args.TryGetProperty("steps", out var stepsEl) && stepsEl.ValueKind == JsonValueKind.Array)
{
foreach (var step in stepsEl.EnumerateArray())
{
var s = step.GetString();
if (!string.IsNullOrWhiteSpace(s))
steps.Add(s);
}
}
if (steps.Count == 0)
return ToolResult.Fail("최소 1개 이상의 step이 필요합니다.");
// tools_used 파싱
var toolsUsed = new List<string>();
if (args.TryGetProperty("tools_used", out var toolsEl) && toolsEl.ValueKind == JsonValueKind.Array)
{
foreach (var tool in toolsEl.EnumerateArray())
{
var t = tool.GetString();
if (!string.IsNullOrWhiteSpace(t))
toolsUsed.Add(t);
}
}
try
{
Directory.CreateDirectory(playbookDir);
// 다음 ID 결정
var nextId = GetNextId(playbookDir);
// 파일명에서 비안전 문자 제거
var safeName = string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
var fileName = $"{nextId}_{safeName}.json";
var playbook = new PlaybookData
{
Id = nextId,
Name = name,
Description = description,
Steps = steps,
ToolsUsed = toolsUsed,
CreatedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
};
var json = JsonSerializer.Serialize(playbook, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
});
var filePath = Path.Combine(playbookDir, fileName);
await TextFileCodec.WriteAllTextAsync(filePath, json, TextFileCodec.Utf8NoBom, ct);
return ToolResult.Ok($"플레이북 저장 완료: [{nextId}] {name}\n단계 수: {steps.Count}, 사용 도구: {toolsUsed.Count}개");
}
catch (Exception ex)
{
return ToolResult.Fail($"플레이북 저장 오류: {ex.Message}");
}
}
private static ToolResult ListPlaybooks(string playbookDir)
{
if (!Directory.Exists(playbookDir))
return ToolResult.Ok("저장된 플레이북이 없습니다.");
var files = Directory.GetFiles(playbookDir, "*.json")
.OrderBy(f => f)
.ToList();
if (files.Count == 0)
return ToolResult.Ok("저장된 플레이북이 없습니다.");
var sb = new StringBuilder();
sb.AppendLine($"플레이북 {files.Count}개:");
foreach (var file in files)
{
try
{
var json = TextFileCodec.ReadAllText(file).Text;
var pb = JsonSerializer.Deserialize<PlaybookData>(json);
if (pb != null)
sb.AppendLine($" [{pb.Id}] {pb.Name} — {pb.Description} ({pb.Steps.Count}단계, {pb.CreatedAt})");
}
catch
{
sb.AppendLine($" [?] {Path.GetFileName(file)} — 파싱 오류");
}
}
return ToolResult.Ok(sb.ToString());
}
private static async Task<ToolResult> DescribePlaybook(JsonElement args, string playbookDir, CancellationToken ct)
{
if (!args.TryGetProperty("id", out var idEl))
return ToolResult.Fail("플레이북 id가 필요합니다.");
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
var playbook = await FindPlaybookById(playbookDir, id, ct);
if (playbook == null)
return ToolResult.Fail($"ID {id}의 플레이북을 찾을 수 없습니다.");
var sb = new StringBuilder();
sb.AppendLine($"플레이북: {playbook.Name} (ID: {playbook.Id})");
sb.AppendLine($"설명: {playbook.Description}");
sb.AppendLine($"생성일: {playbook.CreatedAt}");
sb.AppendLine();
sb.AppendLine("단계:");
for (var i = 0; i < playbook.Steps.Count; i++)
sb.AppendLine($" {i + 1}. {playbook.Steps[i]}");
if (playbook.ToolsUsed.Count > 0)
{
sb.AppendLine();
sb.AppendLine($"사용 도구: {string.Join(", ", playbook.ToolsUsed)}");
}
return ToolResult.Ok(sb.ToString());
}
private static ToolResult DeletePlaybook(JsonElement args, string playbookDir)
{
if (!args.TryGetProperty("id", out var idEl))
return ToolResult.Fail("삭제할 플레이북 id가 필요합니다.");
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
if (!Directory.Exists(playbookDir))
return ToolResult.Fail("저장된 플레이북이 없습니다.");
// ID에 해당하는 파일 찾기
var files = Directory.GetFiles(playbookDir, $"{id}_*.json");
if (files.Length == 0)
{
// 파일 내부 ID로 검색
foreach (var file in Directory.GetFiles(playbookDir, "*.json"))
{
try
{
var json = TextFileCodec.ReadAllText(file).Text;
var pb = JsonSerializer.Deserialize<PlaybookData>(json);
if (pb?.Id == id)
{
var name = pb.Name;
File.Delete(file);
return ToolResult.Ok($"플레이북 삭제됨: [{id}] {name}");
}
}
catch { /* 무시 */ }
}
return ToolResult.Fail($"ID {id}의 플레이북을 찾을 수 없습니다.");
}
try
{
var fileName = Path.GetFileNameWithoutExtension(files[0]);
File.Delete(files[0]);
return ToolResult.Ok($"플레이북 삭제됨: {fileName}");
}
catch (Exception ex)
{
return ToolResult.Fail($"플레이북 삭제 오류: {ex.Message}");
}
}
#region Helpers
private static int GetNextId(string playbookDir)
{
if (!Directory.Exists(playbookDir))
return 1;
var maxId = 0;
foreach (var file in Directory.GetFiles(playbookDir, "*.json"))
{
try
{
var json = TextFileCodec.ReadAllText(file).Text;
var pb = JsonSerializer.Deserialize<PlaybookData>(json);
if (pb != null && pb.Id > maxId)
maxId = pb.Id;
}
catch { /* 무시 */ }
}
return maxId + 1;
}
private static async Task<PlaybookData?> FindPlaybookById(string playbookDir, int id, CancellationToken ct)
{
if (!Directory.Exists(playbookDir))
return null;
foreach (var file in Directory.GetFiles(playbookDir, "*.json"))
{
try
{
var json = (await TextFileCodec.ReadAllTextAsync(file, ct)).Text;
var pb = JsonSerializer.Deserialize<PlaybookData>(json);
if (pb?.Id == id)
return pb;
}
catch { /* 무시 */ }
}
return null;
}
#endregion
#region Models
private class PlaybookData
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public List<string> Steps { get; set; } = [];
public List<string> ToolsUsed { get; set; } = [];
public string CreatedAt { get; set; } = "";
}
#endregion
}

View File

@@ -0,0 +1,270 @@
using System.IO;
using System.Text.Json;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Presentation;
using P = DocumentFormat.OpenXml.Presentation;
using A = DocumentFormat.OpenXml.Drawing;
namespace AxCopilot.Services.Agent;
/// <summary>
/// PowerPoint (.pptx) 프레젠테이션을 네이티브 생성하는 스킬.
/// OpenXML SDK를 사용하여 Python/Node 의존 없이 PPTX를 생성합니다.
/// </summary>
public class PptxSkill : IAgentTool
{
public string Name => "pptx_create";
public string Description =>
"Create a PowerPoint (.pptx) presentation. " +
"Supports slide layouts: title (title+subtitle), content (title+body text), " +
"two_column (title+left+right), table (title+headers+rows), blank. " +
"No external runtime required (native OpenXML).";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "Output file path (.pptx). Relative to work folder." },
["title"] = new() { Type = "string", Description = "Presentation title (used on first slide if no explicit title slide)." },
["slides"] = new()
{
Type = "array",
Description = "Array of slide objects. Each slide: " +
"{\"layout\": \"title|content|two_column|table|blank\", " +
"\"title\": \"Slide Title\", " +
"\"subtitle\": \"...\", " + // title layout
"\"body\": \"...\", " + // content layout
"\"left\": \"...\", \"right\": \"...\", " + // two_column
"\"headers\": [...], \"rows\": [[...]], " + // table
"\"notes\": \"Speaker notes\"}",
Items = new() { Type = "object" }
},
["theme"] = new()
{
Type = "string",
Description = "Color theme: professional (blue), modern (teal), dark (dark gray), minimal (light). Default: professional",
Enum = ["professional", "modern", "dark", "minimal"]
},
},
Required = ["path", "slides"]
};
// 테마별 색상 정의
private static readonly Dictionary<string, (string Primary, string Accent, string TextDark, string TextLight, string Bg)> Themes = new()
{
["professional"] = ("2B579A", "4B5EFC", "1A1A2E", "FFFFFF", "FFFFFF"),
["modern"] = ("0D9488", "06B6D4", "1A1A2E", "FFFFFF", "FFFFFF"),
["dark"] = ("374151", "6366F1", "F9FAFB", "FFFFFF", "1F2937"),
["minimal"] = ("6B7280", "3B82F6", "111827", "FFFFFF", "FAFAFA"),
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var presTitle = args.TryGetProperty("title", out var tt) ? tt.GetString() ?? "Presentation" : "Presentation";
var theme = args.TryGetProperty("theme", out var th) ? th.GetString() ?? "professional" : "professional";
if (!args.TryGetProperty("slides", out var slidesEl) || slidesEl.ValueKind != JsonValueKind.Array)
return ToolResult.Fail("slides 배열이 필요합니다.");
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
if (!fullPath.EndsWith(".pptx", StringComparison.OrdinalIgnoreCase))
fullPath += ".pptx";
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
if (!Themes.TryGetValue(theme, out var colors))
colors = Themes["professional"];
try
{
using var pres = PresentationDocument.Create(fullPath, PresentationDocumentType.Presentation);
var presPart = pres.AddPresentationPart();
presPart.Presentation = new P.Presentation();
presPart.Presentation.SlideIdList = new SlideIdList();
presPart.Presentation.SlideSize = new SlideSize
{
Cx = 12192000, // 10 inches
Cy = 6858000, // 7.5 inches
};
presPart.Presentation.NotesSize = new NotesSize { Cx = 6858000, Cy = 9144000 };
uint slideId = 256;
int slideCount = 0;
foreach (var slideEl in slidesEl.EnumerateArray())
{
var layout = slideEl.TryGetProperty("layout", out var lay) ? lay.GetString() ?? "content" : "content";
var slideTitle = slideEl.TryGetProperty("title", out var st) ? st.GetString() ?? "" : "";
var subtitle = slideEl.TryGetProperty("subtitle", out var sub) ? sub.GetString() ?? "" : "";
var body = slideEl.TryGetProperty("body", out var bd) ? bd.GetString() ?? "" : "";
var left = slideEl.TryGetProperty("left", out var lf) ? lf.GetString() ?? "" : "";
var right = slideEl.TryGetProperty("right", out var rt) ? rt.GetString() ?? "" : "";
var notes = slideEl.TryGetProperty("notes", out var nt) ? nt.GetString() ?? "" : "";
var slidePart = presPart.AddNewPart<SlidePart>();
slidePart.Slide = new Slide(new CommonSlideData(new ShapeTree(
new P.NonVisualGroupShapeProperties(
new P.NonVisualDrawingProperties { Id = 1, Name = "" },
new P.NonVisualGroupShapeDrawingProperties(),
new ApplicationNonVisualDrawingProperties()),
new GroupShapeProperties(new A.TransformGroup())
)));
var shapeTree = slidePart.Slide.CommonSlideData!.ShapeTree!;
uint shapeId = 2;
switch (layout)
{
case "title":
// 배경 색상 박스
AddRectangle(shapeTree, ref shapeId, 0, 0, 12192000, 6858000, colors.Primary);
// 타이틀
AddTextBox(shapeTree, ref shapeId, 600000, 2000000, 10992000, 1400000,
slideTitle, 3600, colors.TextLight, true);
// 서브타이틀
if (!string.IsNullOrEmpty(subtitle))
AddTextBox(shapeTree, ref shapeId, 600000, 3600000, 10992000, 800000,
subtitle, 2000, colors.TextLight, false);
break;
case "two_column":
AddTextBox(shapeTree, ref shapeId, 600000, 300000, 10992000, 800000,
slideTitle, 2800, colors.Primary, true);
// 왼쪽 컬럼
AddTextBox(shapeTree, ref shapeId, 600000, 1300000, 5200000, 5000000,
left, 1600, colors.TextDark, false);
// 오른쪽 컬럼
AddTextBox(shapeTree, ref shapeId, 6400000, 1300000, 5200000, 5000000,
right, 1600, colors.TextDark, false);
break;
case "table":
AddTextBox(shapeTree, ref shapeId, 600000, 300000, 10992000, 800000,
slideTitle, 2800, colors.Primary, true);
// 테이블은 텍스트로 시뮬레이션 (OpenXML 테이블은 매우 복잡)
var tableText = FormatTableAsText(slideEl, colors);
AddTextBox(shapeTree, ref shapeId, 600000, 1300000, 10992000, 5000000,
tableText, 1400, colors.TextDark, false);
break;
case "blank":
// 빈 슬라이드
break;
default: // content
AddTextBox(shapeTree, ref shapeId, 600000, 300000, 10992000, 800000,
slideTitle, 2800, colors.Primary, true);
AddTextBox(shapeTree, ref shapeId, 600000, 1300000, 10992000, 5000000,
body, 1600, colors.TextDark, false);
break;
}
// 슬라이드 등록
presPart.Presentation.SlideIdList.AppendChild(new SlideId
{
Id = slideId++,
RelationshipId = presPart.GetIdOfPart(slidePart)
});
slideCount++;
}
presPart.Presentation.Save();
return ToolResult.Ok(
$"✅ PPTX 생성 완료: {Path.GetFileName(fullPath)} ({slideCount}슬라이드, 테마: {theme})",
fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"PPTX 생성 실패: {ex.Message}");
}
}
private static void AddTextBox(ShapeTree tree, ref uint id, long x, long y, long cx, long cy,
string text, int fontSize, string color, bool bold)
{
var shape = new Shape();
shape.NonVisualShapeProperties = new P.NonVisualShapeProperties(
new P.NonVisualDrawingProperties { Id = id++, Name = $"TextBox{id}" },
new P.NonVisualShapeDrawingProperties(new A.ShapeLocks { NoGrouping = true }),
new ApplicationNonVisualDrawingProperties());
shape.ShapeProperties = new ShapeProperties(
new A.Transform2D(
new A.Offset { X = x, Y = y },
new A.Extents { Cx = cx, Cy = cy }),
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle });
var txBody = new TextBody(
new A.BodyProperties { Wrap = A.TextWrappingValues.Square },
new A.ListStyle());
// 텍스트를 줄 단위로 분리
var lines = text.Split('\n');
foreach (var line in lines)
{
var para = new A.Paragraph();
var run = new A.Run();
var runProps = new A.RunProperties { Language = "ko-KR", FontSize = fontSize, Dirty = false };
runProps.AppendChild(new A.SolidFill(new A.RgbColorModelHex { Val = color }));
if (bold) runProps.Bold = true;
run.AppendChild(runProps);
run.AppendChild(new A.Text(line));
para.AppendChild(run);
txBody.AppendChild(para);
}
shape.TextBody = txBody;
tree.AppendChild(shape);
}
private static void AddRectangle(ShapeTree tree, ref uint id, long x, long y, long cx, long cy, string fillColor)
{
var shape = new Shape();
shape.NonVisualShapeProperties = new P.NonVisualShapeProperties(
new P.NonVisualDrawingProperties { Id = id++, Name = $"Rect{id}" },
new P.NonVisualShapeDrawingProperties(),
new ApplicationNonVisualDrawingProperties());
shape.ShapeProperties = new ShapeProperties(
new A.Transform2D(
new A.Offset { X = x, Y = y },
new A.Extents { Cx = cx, Cy = cy }),
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle },
new A.SolidFill(new A.RgbColorModelHex { Val = fillColor }));
tree.AppendChild(shape);
}
private static string FormatTableAsText(JsonElement slideEl, (string Primary, string Accent, string TextDark, string TextLight, string Bg) colors)
{
var sb = new System.Text.StringBuilder();
if (slideEl.TryGetProperty("headers", out var headers))
{
var headerTexts = new List<string>();
foreach (var h in headers.EnumerateArray())
headerTexts.Add(h.GetString() ?? "");
sb.AppendLine(string.Join(" | ", headerTexts));
sb.AppendLine(new string('─', headerTexts.Sum(h => h.Length + 5)));
}
if (slideEl.TryGetProperty("rows", out var rows))
{
foreach (var row in rows.EnumerateArray())
{
var cells = new List<string>();
foreach (var cell in row.EnumerateArray())
cells.Add(cell.GetString() ?? cell.ToString());
sb.AppendLine(string.Join(" | ", cells));
}
}
return sb.ToString();
}
}

View File

@@ -0,0 +1,129 @@
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>cmd/powershell 명령을 실행하는 도구. 타임아웃 및 위험 명령 차단 포함.</summary>
public class ProcessTool : IAgentTool
{
public string Name => "process";
public string Description => "Execute a shell command (cmd or powershell). Returns stdout and stderr. Has a timeout limit.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["command"] = new() { Type = "string", Description = "Command to execute" },
["shell"] = new() { Type = "string", Description = "Shell to use: 'cmd' or 'powershell'. Default: 'cmd'.",
Enum = ["cmd", "powershell"] },
["timeout"] = new() { Type = "integer", Description = "Timeout in seconds. Default: 30, max: 120." },
},
Required = ["command"]
};
// 위험 명령 패턴 (대소문자 무시)
private static readonly string[] DangerousPatterns =
[
"format ", "del /s", "rd /s", "rmdir /s",
"rm -rf", "Remove-Item -Recurse -Force",
"Stop-Computer", "Restart-Computer",
"shutdown", "taskkill /f",
"reg delete", "reg add",
"net user", "net localgroup",
"schtasks /create", "schtasks /delete",
];
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("command", out var cmdEl))
return ToolResult.Fail("command가 필요합니다.");
var command = cmdEl.GetString() ?? "";
var shell = args.TryGetProperty("shell", out var sh) ? sh.GetString() ?? "cmd" : "cmd";
var timeout = args.TryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 120) : 30;
if (string.IsNullOrWhiteSpace(command))
return ToolResult.Fail("명령이 비어 있습니다.");
// 위험 명령 차단
foreach (var pattern in DangerousPatterns)
{
if (command.Contains(pattern, StringComparison.OrdinalIgnoreCase))
return ToolResult.Fail($"위험 명령 차단: '{pattern}' 패턴이 감지되었습니다.");
}
// 권한 확인
if (!await context.CheckWritePermissionAsync(Name, command))
return ToolResult.Fail("명령 실행 권한 거부");
try
{
var (fileName, arguments) = shell == "powershell"
? ("powershell.exe", $"-NoProfile -NonInteractive -Command \"{command.Replace("\"", "\\\"")}\"")
: ("cmd.exe", $"/C {command}");
var psi = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
// 작업 폴더 설정
if (!string.IsNullOrEmpty(context.WorkFolder) && Directory.Exists(context.WorkFolder))
psi.WorkingDirectory = context.WorkFolder;
using var process = new Process { StartInfo = psi };
var stdout = new StringBuilder();
var stderr = new StringBuilder();
process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); };
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
try
{
await process.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
try { process.Kill(entireProcessTree: true); } catch { }
return ToolResult.Fail($"명령 실행 타임아웃 ({timeout}초 초과)");
}
var output = stdout.ToString().TrimEnd();
var error = stderr.ToString().TrimEnd();
// 출력 크기 제한 (8000자)
if (output.Length > 8000)
output = output[..8000] + "\n... (출력 잘림)";
var result = new StringBuilder();
result.AppendLine($"[Exit code: {process.ExitCode}]");
if (!string.IsNullOrEmpty(output))
result.AppendLine(output);
if (!string.IsNullOrEmpty(error))
result.AppendLine($"[stderr]\n{error}");
return process.ExitCode == 0
? ToolResult.Ok(result.ToString())
: ToolResult.Ok(result.ToString()); // 비정상 종료도 결과로 반환 (LLM이 판단)
}
catch (Exception ex)
{
return ToolResult.Fail($"명령 실행 실패: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,222 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 프로젝트 개발 지침(AGENTS.md) 관리 도구.
/// 작업 폴더의 AGENTS.md 파일에 개발 규칙, 코딩 컨벤션, 설계 원칙을 읽고 쓸 수 있습니다.
/// 쓰기 시 사용자 승인을 받습니다.
/// </summary>
public class ProjectRuleTool : IAgentTool
{
public string Name => "project_rules";
public string Description =>
"프로젝트 개발 지침(AGENTS.md) 및 규칙(.ax/rules/)을 관리합니다.\n" +
"- read: 현재 AGENTS.md 내용을 읽습니다\n" +
"- append: 새 규칙/지침을 AGENTS.md에 추가합니다 (사용자 승인 필요)\n" +
"- write: AGENTS.md를 새 내용으로 덮어씁니다 (사용자 승인 필요)\n" +
"- list_rules: .ax/rules/ 디렉토리의 프로젝트 규칙 파일 목록을 조회합니다\n" +
"- read_rule: .ax/rules/ 디렉토리의 특정 규칙 파일을 읽습니다\n" +
"사용자가 '개발 지침에 추가해', '규칙을 저장해', 'AGENTS.md에 기록해' 등을 요청하면 이 도구를 사용하세요.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new ToolProperty
{
Type = "string",
Description = "read (AGENTS.md 읽기), append (추가), write (전체 덮어쓰기), list_rules (.ax/rules/ 목록), read_rule (규칙 파일 읽기)",
Enum = ["read", "append", "write", "list_rules", "read_rule"]
},
["rule_name"] = new ToolProperty
{
Type = "string",
Description = "read_rule 시 읽을 규칙 파일 이름 (확장자 제외). 예: 'coding-conventions'"
},
["content"] = new ToolProperty
{
Type = "string",
Description = "append/write 시 저장할 내용. 마크다운 형식을 권장합니다."
},
["section"] = new ToolProperty
{
Type = "string",
Description = "append 시 섹션 제목. 예: '코딩 컨벤션', '빌드 규칙'. 비어있으면 파일 끝에 추가."
},
},
Required = ["action"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
var section = args.TryGetProperty("section", out var s) ? s.GetString() ?? "" : "";
var ruleName = args.TryGetProperty("rule_name", out var rn) ? rn.GetString() ?? "" : "";
if (string.IsNullOrEmpty(context.WorkFolder))
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
var axMdPath = FindAxMd(context.WorkFolder) ?? Path.Combine(context.WorkFolder, "AGENTS.md");
return action switch
{
"read" => ReadAxMd(axMdPath),
"append" => await AppendAxMdAsync(axMdPath, content, section, context),
"write" => await WriteAxMdAsync(axMdPath, content, context),
"list_rules" => ListRules(context.WorkFolder),
"read_rule" => ReadRule(context.WorkFolder, ruleName),
_ => ToolResult.Fail($"지원하지 않는 action: {action}. read, append, write, list_rules, read_rule 중 선택하세요.")
};
}
private static ToolResult ReadAxMd(string path)
{
if (!File.Exists(path))
return ToolResult.Ok($"AGENTS.md 파일이 없습니다.\n경로: {path}\n\n새로 생성하려면 append 또는 write 액션을 사용하세요.");
try
{
var content = File.ReadAllText(path, Encoding.UTF8);
if (string.IsNullOrWhiteSpace(content))
return ToolResult.Ok("AGENTS.md 파일이 비어 있습니다.");
return ToolResult.Ok($"[AGENTS.md 내용 ({content.Length}자)]\n\n{content}");
}
catch (Exception ex)
{
return ToolResult.Fail($"AGENTS.md 읽기 실패: {ex.Message}");
}
}
private static async Task<ToolResult> AppendAxMdAsync(string path, string content, string section, AgentContext context)
{
if (string.IsNullOrWhiteSpace(content))
return ToolResult.Fail("추가할 content가 필요합니다.");
// 사용자 승인
var desc = $"AGENTS.md에 개발 지침을 추가합니다:\n{(content.Length > 200 ? content[..200] + "..." : content)}";
if (!await context.CheckWritePermissionAsync("project_rules", desc))
return ToolResult.Ok("사용자가 AGENTS.md 수정을 거부했습니다.");
try
{
var sb = new StringBuilder();
// 기존 내용 보존
if (File.Exists(path))
sb.Append(File.ReadAllText(path, Encoding.UTF8));
// 구분선 + 섹션 제목
if (sb.Length > 0 && !sb.ToString().EndsWith('\n'))
sb.AppendLine();
sb.AppendLine();
if (!string.IsNullOrEmpty(section))
sb.AppendLine($"## {section}");
sb.AppendLine(content.Trim());
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
return ToolResult.Ok($"AGENTS.md에 개발 지침이 추가되었습니다.\n경로: {path}\n추가된 내용 ({content.Length}자):\n{content}", path);
}
catch (Exception ex)
{
return ToolResult.Fail($"AGENTS.md 쓰기 실패: {ex.Message}");
}
}
private static async Task<ToolResult> WriteAxMdAsync(string path, string content, AgentContext context)
{
if (string.IsNullOrWhiteSpace(content))
return ToolResult.Fail("저장할 content가 필요합니다.");
var desc = $"AGENTS.md를 전체 덮어씁니다 ({content.Length}자):\n{(content.Length > 200 ? content[..200] + "..." : content)}";
if (!await context.CheckWritePermissionAsync("project_rules", desc))
return ToolResult.Ok("사용자가 AGENTS.md 수정을 거부했습니다.");
try
{
File.WriteAllText(path, content, Encoding.UTF8);
return ToolResult.Ok($"AGENTS.md가 저장되었습니다.\n경로: {path}\n내용 ({content.Length}자)", path);
}
catch (Exception ex)
{
return ToolResult.Fail($"AGENTS.md 쓰기 실패: {ex.Message}");
}
}
private static ToolResult ListRules(string workFolder)
{
var rules = ProjectRulesService.LoadRules(workFolder);
if (rules.Count == 0)
{
var rulesDir = ProjectRulesService.FindRulesDirectory(workFolder);
var suggestedPath = rulesDir ?? Path.Combine(workFolder, ".ax", "rules");
return ToolResult.Ok(
$"프로젝트 규칙이 없습니다.\n" +
$"규칙 파일을 추가하려면 {suggestedPath} 디렉토리에 .md 파일을 생성하세요.\n\n" +
"예시 규칙 파일 형식:\n" +
"---\nname: 코딩 컨벤션\ndescription: C# 코딩 규칙\napplies-to: \"*.cs\"\nwhen: always\n---\n\n규칙 내용...");
}
var sb = new StringBuilder();
sb.AppendLine($"[프로젝트 규칙 {rules.Count}개]\n");
foreach (var rule in rules)
{
sb.AppendLine($" • {rule.Name}");
if (!string.IsNullOrEmpty(rule.Description))
sb.AppendLine($" 설명: {rule.Description}");
if (!string.IsNullOrEmpty(rule.AppliesTo))
sb.AppendLine($" 적용 대상: {rule.AppliesTo}");
sb.AppendLine($" 적용 시점: {rule.When}");
sb.AppendLine($" 파일: {rule.FilePath}");
sb.AppendLine();
}
return ToolResult.Ok(sb.ToString());
}
private static ToolResult ReadRule(string workFolder, string ruleName)
{
if (string.IsNullOrWhiteSpace(ruleName))
return ToolResult.Fail("rule_name 파라미터가 필요합니다.");
var rules = ProjectRulesService.LoadRules(workFolder);
var rule = rules.FirstOrDefault(r =>
r.Name.Equals(ruleName, StringComparison.OrdinalIgnoreCase) ||
Path.GetFileNameWithoutExtension(r.FilePath).Equals(ruleName, StringComparison.OrdinalIgnoreCase));
if (rule == null)
return ToolResult.Fail($"규칙 '{ruleName}'을(를) 찾을 수 없습니다. list_rules로 목록을 확인하세요.");
var sb = new StringBuilder();
sb.AppendLine($"[규칙: {rule.Name}]");
if (!string.IsNullOrEmpty(rule.Description)) sb.AppendLine($"설명: {rule.Description}");
if (!string.IsNullOrEmpty(rule.AppliesTo)) sb.AppendLine($"적용 대상: {rule.AppliesTo}");
sb.AppendLine($"적용 시점: {rule.When}");
sb.AppendLine($"파일: {rule.FilePath}");
sb.AppendLine();
sb.AppendLine(rule.Body);
return ToolResult.Ok(sb.ToString(), rule.FilePath);
}
/// <summary>AGENTS.md 파일을 작업 폴더에서 최대 3단계 상위까지 탐색합니다 (없으면 AX.md 폴백).</summary>
private static string? FindAxMd(string workFolder)
{
var dir = workFolder;
for (int i = 0; i < 3; i++)
{
if (string.IsNullOrEmpty(dir)) break;
var agentsPath = Path.Combine(dir, "AGENTS.md");
if (File.Exists(agentsPath)) return agentsPath;
var legacyPath = Path.Combine(dir, "AX.md");
if (File.Exists(legacyPath)) return legacyPath;
dir = Directory.GetParent(dir)?.FullName;
}
return null;
}
}

View File

@@ -0,0 +1,206 @@
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 프로젝트별 규칙 파일(.ax/rules/*.md)을 로드하고 컨텍스트에 맞는 규칙을 선별합니다.
/// 각 규칙 파일은 YAML 프론트매터로 적용 조건을 정의합니다.
/// </summary>
public static class ProjectRulesService
{
/// <summary>파싱된 프로젝트 규칙.</summary>
public class ProjectRule
{
/// <summary>규칙 파일 경로.</summary>
public string FilePath { get; set; } = "";
/// <summary>규칙 이름 (프론트매터 name 또는 파일명).</summary>
public string Name { get; set; } = "";
/// <summary>규칙 설명.</summary>
public string Description { get; set; } = "";
/// <summary>적용 대상 글로브 패턴. 예: "*.cs", "src/**/*.ts"</summary>
public string AppliesTo { get; set; } = "";
/// <summary>적용 시점. 예: "always", "code-review", "document", "refactor"</summary>
public string When { get; set; } = "always";
/// <summary>규칙 본문 (프론트매터 제외).</summary>
public string Body { get; set; } = "";
}
// YAML 프론트매터 경계
private static readonly Regex FrontMatterRegex = new(
@"^---\s*\n(.*?)\n---\s*\n",
RegexOptions.Singleline | RegexOptions.Compiled);
// YAML 키-값 파싱 (단순 1줄 값만)
private static readonly Regex YamlKeyValue = new(
@"^\s*(\w[\w-]*)\s*:\s*(.+?)\s*$",
RegexOptions.Multiline | RegexOptions.Compiled);
/// <summary>
/// 작업 폴더에서 .ax/rules/ 디렉토리의 모든 규칙을 로드합니다.
/// 최대 3단계 상위 폴더까지 .ax/rules/ 를 탐색합니다.
/// </summary>
public static List<ProjectRule> LoadRules(string workFolder)
{
var rules = new List<ProjectRule>();
if (string.IsNullOrEmpty(workFolder)) return rules;
var rulesDir = FindRulesDirectory(workFolder);
if (rulesDir == null) return rules;
try
{
foreach (var file in Directory.GetFiles(rulesDir, "*.md"))
{
var rule = ParseRuleFile(file);
if (rule != null)
rules.Add(rule);
}
}
catch { /* 디렉토리 읽기 실패 시 빈 목록 반환 */ }
return rules;
}
/// <summary>
/// 컨텍스트에 맞는 규칙만 필터링합니다.
/// </summary>
/// <param name="rules">전체 규칙 목록</param>
/// <param name="when">현재 컨텍스트 (예: "code-review", "document", "always")</param>
/// <param name="filePaths">현재 작업 대상 파일 경로들 (applies-to 매칭용)</param>
public static List<ProjectRule> FilterRules(
List<ProjectRule> rules, string when = "always", IEnumerable<string>? filePaths = null)
{
var result = new List<ProjectRule>();
foreach (var rule in rules)
{
// when 조건 체크
var ruleWhen = rule.When.ToLowerInvariant().Trim();
if (ruleWhen != "always" && ruleWhen != when.ToLowerInvariant())
continue;
// applies-to 조건 체크
if (!string.IsNullOrEmpty(rule.AppliesTo) && filePaths != null)
{
var pattern = rule.AppliesTo.Trim();
if (!filePaths.Any(fp => MatchesGlob(fp, pattern)))
continue;
}
result.Add(rule);
}
return result;
}
/// <summary>
/// 규칙 목록을 시스템 프롬프트용 텍스트로 포맷합니다.
/// </summary>
public static string FormatForSystemPrompt(List<ProjectRule> rules)
{
if (rules.Count == 0) return "";
var sb = new StringBuilder();
sb.AppendLine("\n## 프로젝트 규칙 (.ax/rules/)");
sb.AppendLine("아래 규칙을 반드시 준수하세요:\n");
foreach (var rule in rules)
{
if (!string.IsNullOrEmpty(rule.Name))
sb.AppendLine($"### {rule.Name}");
if (!string.IsNullOrEmpty(rule.Description))
sb.AppendLine($"*{rule.Description}*\n");
sb.AppendLine(rule.Body.Trim());
sb.AppendLine();
}
return sb.ToString();
}
/// <summary>.ax/rules/ 디렉토리를 작업 폴더에서 최대 3단계 상위까지 탐색합니다.</summary>
internal static string? FindRulesDirectory(string workFolder)
{
var dir = workFolder;
for (int i = 0; i < 3; i++)
{
if (string.IsNullOrEmpty(dir)) break;
var rulesPath = Path.Combine(dir, ".ax", "rules");
if (Directory.Exists(rulesPath)) return rulesPath;
dir = Directory.GetParent(dir)?.FullName;
}
return null;
}
/// <summary>규칙 파일을 파싱합니다 (YAML 프론트매터 + 본문).</summary>
internal static ProjectRule? ParseRuleFile(string filePath)
{
try
{
var content = File.ReadAllText(filePath, Encoding.UTF8);
if (string.IsNullOrWhiteSpace(content)) return null;
var rule = new ProjectRule
{
FilePath = filePath,
Name = Path.GetFileNameWithoutExtension(filePath),
};
// 프론트매터 파싱
var fmMatch = FrontMatterRegex.Match(content);
if (fmMatch.Success)
{
var yaml = fmMatch.Groups[1].Value;
foreach (Match kv in YamlKeyValue.Matches(yaml))
{
var key = kv.Groups[1].Value.ToLowerInvariant();
var val = kv.Groups[2].Value.Trim().Trim('"', '\'');
switch (key)
{
case "name": rule.Name = val; break;
case "description": rule.Description = val; break;
case "applies-to" or "appliesto": rule.AppliesTo = val; break;
case "when": rule.When = val; break;
}
}
rule.Body = content[(fmMatch.Index + fmMatch.Length)..];
}
else
{
rule.Body = content;
}
return string.IsNullOrWhiteSpace(rule.Body) ? null : rule;
}
catch
{
return null;
}
}
/// <summary>간단한 글로브 패턴 매칭 (*, ** 지원).</summary>
private static bool MatchesGlob(string path, string pattern)
{
// "*.cs" → 확장자 매칭
if (pattern.StartsWith("*."))
return path.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase);
// "**/*.cs" → 경로 내 확장자 매칭
if (pattern.StartsWith("**/"))
{
var subPattern = pattern[3..];
return MatchesGlob(Path.GetFileName(path), subPattern);
}
// 정확한 파일명 매칭
return Path.GetFileName(path).Equals(pattern, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,188 @@
using System.Text.Json;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 정규식 테스트·추출·치환 도구.
/// 패턴 매칭, 그룹 추출, 치환, 패턴 설명 기능을 제공합니다.
/// </summary>
public class RegexTool : IAgentTool
{
public string Name => "regex_tool";
public string Description =>
"Regular expression tool. Actions: " +
"'test' — check if text matches a pattern; " +
"'match' — find all matches with groups; " +
"'replace' — replace matches with replacement string; " +
"'split' — split text by pattern; " +
"'extract' — extract named/numbered groups from first match.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "Action to perform",
Enum = ["test", "match", "replace", "split", "extract"],
},
["pattern"] = new()
{
Type = "string",
Description = "Regular expression pattern",
},
["text"] = new()
{
Type = "string",
Description = "Text to process",
},
["replacement"] = new()
{
Type = "string",
Description = "Replacement string for replace action (supports $1, $2, ${name})",
},
["flags"] = new()
{
Type = "string",
Description = "Regex flags: 'i' (ignore case), 'm' (multiline), 's' (singleline). Combine: 'im'",
},
},
Required = ["action", "pattern", "text"],
};
private const int MaxTimeout = 5000; // ReDoS 방지
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var pattern = args.GetProperty("pattern").GetString() ?? "";
var text = args.GetProperty("text").GetString() ?? "";
var replacement = args.TryGetProperty("replacement", out var r) ? r.GetString() ?? "" : "";
var flags = args.TryGetProperty("flags", out var f) ? f.GetString() ?? "" : "";
try
{
var options = ParseFlags(flags);
var regex = new Regex(pattern, options, TimeSpan.FromMilliseconds(MaxTimeout));
return Task.FromResult(action switch
{
"test" => Test(regex, text),
"match" => Match(regex, text),
"replace" => Replace(regex, text, replacement),
"split" => Split(regex, text),
"extract" => Extract(regex, text),
_ => ToolResult.Fail($"Unknown action: {action}"),
});
}
catch (RegexMatchTimeoutException)
{
return Task.FromResult(ToolResult.Fail("정규식 실행 시간 초과 (ReDoS 방지). 패턴을 간소화하세요."));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"정규식 오류: {ex.Message}"));
}
}
private static RegexOptions ParseFlags(string flags)
{
var options = RegexOptions.None;
foreach (var c in flags)
{
options |= c switch
{
'i' => RegexOptions.IgnoreCase,
'm' => RegexOptions.Multiline,
's' => RegexOptions.Singleline,
_ => RegexOptions.None,
};
}
return options;
}
private static ToolResult Test(Regex regex, string text)
{
var isMatch = regex.IsMatch(text);
return ToolResult.Ok(isMatch ? "✓ Pattern matches" : "✗ No match");
}
private static ToolResult Match(Regex regex, string text)
{
var matches = regex.Matches(text);
if (matches.Count == 0)
return ToolResult.Ok("No matches found.");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"Found {matches.Count} match(es):");
var limit = Math.Min(matches.Count, 50); // 최대 50개
for (var i = 0; i < limit; i++)
{
var m = matches[i];
sb.AppendLine($"\n[{i}] \"{Truncate(m.Value, 200)}\" (index {m.Index}, length {m.Length})");
if (m.Groups.Count > 1)
{
for (var g = 1; g < m.Groups.Count; g++)
{
var group = m.Groups[g];
var name = regex.GroupNameFromNumber(g);
var label = name != g.ToString() ? $"'{name}'" : $"${g}";
sb.AppendLine($" Group {label}: \"{Truncate(group.Value, 100)}\"");
}
}
}
if (matches.Count > limit)
sb.AppendLine($"\n... and {matches.Count - limit} more matches");
return ToolResult.Ok(sb.ToString());
}
private static ToolResult Replace(Regex regex, string text, string replacement)
{
var result = regex.Replace(text, replacement);
var count = regex.Matches(text).Count;
if (result.Length > 8000)
result = result[..8000] + "\n... (truncated)";
return ToolResult.Ok($"Replaced {count} occurrence(s):\n\n{result}");
}
private static ToolResult Split(Regex regex, string text)
{
var parts = regex.Split(text);
var sb = new System.Text.StringBuilder();
sb.AppendLine($"Split into {parts.Length} parts:");
var limit = Math.Min(parts.Length, 100);
for (var i = 0; i < limit; i++)
sb.AppendLine($" [{i}] \"{Truncate(parts[i], 200)}\"");
if (parts.Length > limit)
sb.AppendLine($"\n... and {parts.Length - limit} more parts");
return ToolResult.Ok(sb.ToString());
}
private static ToolResult Extract(Regex regex, string text)
{
var m = regex.Match(text);
if (!m.Success)
return ToolResult.Ok("No match found.");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"Match: \"{Truncate(m.Value, 300)}\"");
if (m.Groups.Count > 1)
{
sb.AppendLine("\nGroups:");
for (var g = 1; g < m.Groups.Count; g++)
{
var group = m.Groups[g];
var name = regex.GroupNameFromNumber(g);
var label = name != g.ToString() ? $"'{name}'" : $"${g}";
sb.AppendLine($" {label}: \"{Truncate(group.Value, 200)}\"");
}
}
return ToolResult.Ok(sb.ToString());
}
private static string Truncate(string s, int maxLen) =>
s.Length <= maxLen ? s : s[..maxLen] + "…";
}

View File

@@ -0,0 +1,132 @@
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 스킬 관리 에이전트 도구.
/// 로드된 스킬 목록 조회, 스킬 정보 확인, 스킬 실행을 지원합니다.
/// </summary>
public class SkillManagerTool : IAgentTool
{
public string Name => "skill_manager";
public string Description =>
"마크다운 기반 스킬(워크플로우)을 관리합니다.\n" +
"- list: 사용 가능한 스킬 목록 조회\n" +
"- info: 특정 스킬의 상세 정보 확인\n" +
"- reload: 스킬 폴더를 다시 스캔하여 새 스킬 로드";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new ToolProperty
{
Type = "string",
Description = "list (목록), info (상세정보), reload (재로드)",
Enum = ["list", "info", "reload"]
},
["skill_name"] = new ToolProperty
{
Type = "string",
Description = "스킬 이름 (info 액션에서 사용)"
},
},
Required = ["action"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var app = System.Windows.Application.Current as App;
if (!(app?.SettingsService?.Settings.Llm.EnableSkillSystem ?? true))
return ToolResult.Ok("스킬 시스템이 비활성 상태입니다. 설정 → AX Agent → 공통에서 활성화하세요.");
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
var skillName = args.TryGetProperty("skill_name", out var s) ? s.GetString() ?? "" : "";
return action switch
{
"list" => ListSkills(),
"info" => InfoSkill(skillName),
"reload" => ReloadSkills(app),
_ => ToolResult.Fail($"지원하지 않는 action: {action}. list, info, reload 중 선택하세요.")
};
}
private static ToolResult ListSkills()
{
var skills = SkillService.Skills;
if (skills.Count == 0)
return ToolResult.Ok("로드된 스킬이 없습니다. %APPDATA%\\AxCopilot\\skills\\에 *.skill.md 파일을 추가하세요.");
var sb = new StringBuilder();
sb.AppendLine($"사용 가능한 스킬 ({skills.Count}개):\n");
foreach (var skill in skills)
{
var execBadge = string.Equals(skill.ExecutionContext, "fork", StringComparison.OrdinalIgnoreCase)
? "[FORK]"
: "[DIRECT]";
sb.AppendLine($" /{skill.Name} {execBadge} — {skill.Label}");
sb.AppendLine($" {skill.Description}");
if (string.Equals(skill.ExecutionContext, "fork", StringComparison.OrdinalIgnoreCase))
sb.AppendLine(" 실행 방식: 위임 우선 (spawn_agent → wait_agents)");
if (!string.IsNullOrWhiteSpace(skill.AllowedTools))
sb.AppendLine($" allowed-tools: {skill.AllowedTools}");
if (!string.IsNullOrWhiteSpace(skill.Hooks))
sb.AppendLine($" hooks: {skill.Hooks}");
if (!string.IsNullOrWhiteSpace(skill.HookFilters))
sb.AppendLine($" hook-filters: {skill.HookFilters}");
sb.AppendLine();
}
sb.AppendLine("슬래시 명령어(/{name})로 호출하거나, 대화에서 해당 워크플로우를 요청할 수 있습니다.");
return ToolResult.Ok(sb.ToString());
}
private static ToolResult InfoSkill(string name)
{
if (string.IsNullOrEmpty(name))
return ToolResult.Fail("skill_name이 필요합니다.");
var skill = SkillService.Find(name);
if (skill == null)
return ToolResult.Fail($"'{name}' 스킬을 찾을 수 없습니다. skill_manager(action: list)로 목록을 확인하세요.");
var sb = new StringBuilder();
sb.AppendLine($"스킬 상세: {skill.Label} (/{skill.Name})");
sb.AppendLine($"설명: {skill.Description}");
sb.AppendLine($"파일: {skill.FilePath}");
if (string.Equals(skill.ExecutionContext, "fork", StringComparison.OrdinalIgnoreCase))
sb.AppendLine("실행 배지: [FORK] · 위임 우선 실행");
else
sb.AppendLine("실행 배지: [DIRECT] · 일반 실행");
if (!string.IsNullOrWhiteSpace(skill.ExecutionContext))
sb.AppendLine($"context: {skill.ExecutionContext}");
if (!string.IsNullOrWhiteSpace(skill.Agent))
sb.AppendLine($"agent: {skill.Agent}");
if (!string.IsNullOrWhiteSpace(skill.Effort))
sb.AppendLine($"effort: {skill.Effort}");
if (!string.IsNullOrWhiteSpace(skill.Model))
sb.AppendLine($"model: {skill.Model}");
if (skill.DisableModelInvocation)
sb.AppendLine("disable-model-invocation: true");
if (!string.IsNullOrWhiteSpace(skill.AllowedTools))
sb.AppendLine($"allowed-tools: {skill.AllowedTools}");
if (!string.IsNullOrWhiteSpace(skill.Hooks))
sb.AppendLine($"hooks: {skill.Hooks}");
if (!string.IsNullOrWhiteSpace(skill.HookFilters))
sb.AppendLine($"hook-filters: {skill.HookFilters}");
var runtimeDirective = SkillService.BuildRuntimeDirective(skill);
if (!string.IsNullOrWhiteSpace(runtimeDirective))
sb.AppendLine($"\n--- 런타임 정책 ---\n{runtimeDirective}");
sb.AppendLine($"\n--- 시스템 프롬프트 ---\n{skill.SystemPrompt}");
return ToolResult.Ok(sb.ToString());
}
private static ToolResult ReloadSkills(App? app)
{
var customFolder = app?.SettingsService?.Settings.Llm.SkillsFolderPath ?? "";
SkillService.LoadSkills(customFolder);
return ToolResult.Ok($"스킬 재로드 완료. {SkillService.Skills.Count}개 로드됨.");
}
}

View File

@@ -0,0 +1,964 @@
using System.IO;
using System.IO.Compression;
using System.Text;
using AxCopilot.Services;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 마크다운 기반 스킬 정의를 로드/관리하는 서비스.
/// *.skill.md 파일의 YAML 프론트매터를 파싱하여 슬래시 명령으로 노출합니다.
/// 외부 폴더(%APPDATA%\AxCopilot\skills\) 또는 앱 기본 폴더에서 로드합니다.
/// </summary>
public static class SkillService
{
private static List<SkillDefinition> _skills = new();
private static string _lastFolder = "";
private static readonly HashSet<string> _activeConditionalSkillNames = new(StringComparer.OrdinalIgnoreCase);
/// <summary>로드된 스킬 목록.</summary>
public static IReadOnlyList<SkillDefinition> Skills => _skills;
/// <summary>스킬 폴더에서 *.skill.md 파일을 로드합니다.</summary>
public static void LoadSkills(string? customFolder = null)
{
var folders = new List<string>();
// 1) 앱 기본 스킬 폴더
var defaultFolder = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "skills");
if (Directory.Exists(defaultFolder)) folders.Add(defaultFolder);
// 2) 사용자 스킬 폴더 (%APPDATA%\AxCopilot\skills\)
var appDataFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "skills");
if (Directory.Exists(appDataFolder)) folders.Add(appDataFolder);
// 3) 사용자 지정 폴더
if (!string.IsNullOrEmpty(customFolder) && Directory.Exists(customFolder))
folders.Add(customFolder);
var allSkills = new List<SkillDefinition>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var folder in folders)
{
// 1) 기존 형식: *.skill.md 파일
foreach (var file in Directory.GetFiles(folder, "*.skill.md"))
{
try
{
var skill = ParseSkillFile(file);
if (skill != null && seen.Add(skill.Name))
allSkills.Add(skill);
}
catch (Exception ex)
{
LogService.Warn($"스킬 로드 실패 [{file}]: {ex.Message}");
}
}
// 2) SKILL.md 표준: 하위폴더/SKILL.md 구조
try
{
foreach (var subDir in Directory.GetDirectories(folder))
{
var skillMd = Path.Combine(subDir, "SKILL.md");
if (!File.Exists(skillMd)) continue;
try
{
var skill = ParseSkillFile(skillMd);
if (skill != null && seen.Add(skill.Name))
allSkills.Add(skill);
}
catch (Exception ex)
{
LogService.Warn($"스킬 로드 실패 [{skillMd}]: {ex.Message}");
}
}
}
catch { /* 폴더 접근 오류 무시 */ }
}
// 런타임 의존성 검증
foreach (var skill in allSkills)
{
if (!string.IsNullOrEmpty(skill.Requires))
{
var runtimes = skill.Requires.Split(',').Select(r => r.Trim());
skill.IsAvailable = runtimes.All(r => RuntimeDetector.IsAvailable(r));
}
}
_skills = allSkills;
_lastFolder = customFolder ?? "";
_activeConditionalSkillNames.Clear();
var unavailCount = allSkills.Count(s => !s.IsAvailable);
LogService.Info($"스킬 {allSkills.Count}개 로드 완료" +
(unavailCount > 0 ? $" (런타임 미충족 {unavailCount}개)" : ""));
}
/// <summary>스킬 이름으로 검색합니다.</summary>
public static SkillDefinition? Find(string name) =>
_skills.FirstOrDefault(s => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
/// <summary>슬래시 명령어 매칭용: /로 시작하는 텍스트에 매칭되는 스킬 목록.</summary>
public static List<SkillDefinition> MatchSlashCommand(string input)
{
if (!input.StartsWith('/')) return new();
return _skills
.Where(IsSkillInvocableNow)
.Where(s => ("/" + s.Name).StartsWith(input, StringComparison.OrdinalIgnoreCase))
.ToList();
}
/// <summary>슬래시 입력 전체 문자열에서 실제 실행 가능한 스킬을 찾습니다.</summary>
public static SkillDefinition? MatchSlashInvocation(string input)
{
if (string.IsNullOrWhiteSpace(input) || !input.StartsWith('/')) return null;
// 접두어 충돌 방지: 긴 이름 우선
foreach (var skill in _skills
.Where(IsSkillInvocableNow)
.OrderByDescending(s => s.Name.Length))
{
var slashCmd = "/" + skill.Name;
if (input.StartsWith(slashCmd, StringComparison.OrdinalIgnoreCase))
return skill;
}
return null;
}
/// <summary>
/// 스킬 메타데이터(context/agent/effort/model 등)를
/// 런타임 시스템 지시문으로 변환합니다.
/// </summary>
public static string BuildRuntimeDirective(SkillDefinition skill)
{
var lines = new List<string>();
if (!string.IsNullOrWhiteSpace(skill.Model))
lines.Add($"- preferred_model: {skill.Model.Trim()}");
if (!string.IsNullOrWhiteSpace(skill.Effort))
lines.Add($"- reasoning_effort: {NormalizeEffort(skill.Effort)}");
if (!string.IsNullOrWhiteSpace(skill.ExecutionContext) &&
skill.ExecutionContext.Trim().Equals("fork", StringComparison.OrdinalIgnoreCase))
{
lines.Add("- execution_context: fork");
lines.Add("- guidance: 작업을 분리 가능한 경우 spawn_agent로 위임하고, 필요할 때만 wait_agents를 사용하세요.");
}
if (!string.IsNullOrWhiteSpace(skill.Agent))
lines.Add($"- preferred_agent: {skill.Agent.Trim()}");
var allowedTools = ParseAllowedToolsForRuntimePolicy(skill.AllowedTools);
if (allowedTools.Count > 0)
lines.Add($"- allowed_tools: {string.Join(", ", allowedTools)}");
var hookNames = ParseHookNamesForRuntimePolicy(skill.Hooks);
if (hookNames.Count > 0)
lines.Add($"- hook_names: {string.Join(", ", hookNames)}");
var hookFilters = ParseHookNamesForRuntimePolicy(skill.HookFilters);
if (hookFilters.Count > 0)
lines.Add($"- hook_filters: {string.Join(", ", hookFilters)}");
if (skill.DisableModelInvocation)
lines.Add("- disable_model_invocation: 가능하면 도구 실행과 기존 컨텍스트를 우선 사용하고, 불필요한 추가 추론을 줄이세요.");
if (lines.Count == 0)
return "";
return "[Skill Runtime Policy]\n" + string.Join("\n", lines);
}
/// <summary>
/// paths 전면조건 스킬 활성화.
/// claw-code의 conditional skill 활성화 패턴과 동일하게
/// file path 입력이 매칭될 때만 동적으로 활성화합니다.
/// </summary>
public static string[] ActivateConditionalSkillsForPaths(IEnumerable<string> filePaths, string cwd)
{
if (string.IsNullOrWhiteSpace(cwd) || !Directory.Exists(cwd))
return [];
var activated = new List<string>();
foreach (var skill in _skills)
{
if (string.IsNullOrWhiteSpace(skill.Paths) || _activeConditionalSkillNames.Contains(skill.Name))
continue;
var patterns = ParsePathPatterns(skill.Paths);
if (patterns.Count == 0)
continue;
foreach (var filePath in filePaths)
{
var relativePath = ToRelativePathIfUnderCwd(filePath, cwd);
if (string.IsNullOrEmpty(relativePath))
continue;
if (PathMatchesPatterns(relativePath, patterns))
{
_activeConditionalSkillNames.Add(skill.Name);
activated.Add(skill.Name);
break;
}
}
}
if (activated.Count > 0)
LogService.Info($"조건부 스킬 활성화: {string.Join(", ", activated)}");
return activated.ToArray();
}
/// <summary>조건부 스킬 활성화 상태를 초기화합니다.</summary>
public static void ResetConditionalSkillActivation() => _activeConditionalSkillNames.Clear();
/// <summary>스킬 폴더가 없으면 생성하고 예제 스킬을 배치합니다.</summary>
public static void EnsureSkillFolder()
{
var folder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "skills");
if (!Directory.Exists(folder))
Directory.CreateDirectory(folder);
// 예제 스킬이 없으면 생성
CreateExampleSkill(folder, "daily-standup.skill.md",
"daily-standup", "데일리 스탠드업",
"작업 폴더의 최근 변경사항을 요약하여 데일리 스탠드업 보고서를 생성합니다.",
"""
Git .
:
1. git_tool (action: log, args: "--oneline -10")
2. git_tool (action: status)
3. git_tool (action: diff, args: "--stat")
:
## 📋
###
-
### 🔄
-
### /
- TODO/FIXME가
.
""");
CreateExampleSkill(folder, "bug-hunt.skill.md",
"bug-hunt", "버그 탐색",
"작업 폴더에서 잠재적 버그 패턴을 검색합니다.",
"""
.
:
1. grep_tool :
- catch : catch\s*\{\s*\}
- TODO/FIXME: (TODO|FIXME|HACK|XXX)
- .Result/.Wait(): \.(Result|Wait\(\))
- : (password|secret|apikey)\s*=\s*"
2. code_review (action: diff_review, focus: bugs)
:
- 🔴 CRITICAL:
- 🟡 WARNING:
- 🔵 INFO:
.
""");
CreateExampleSkill(folder, "code-explain.skill.md",
"code-explain", "코드 설명",
"지정한 파일의 코드를 상세히 설명합니다.",
"""
.
:
1. file_read
2. folder_map ()
:
-
- /
-
-
- ()
. .
""");
}
// ─── 가져오기/내보내기 ──────────────────────────────────────────────────
/// <summary>
/// 스킬을 zip 파일로 내보냅니다.
/// zip 구조: skill-name/ 폴더 안에 .skill.md 파일 (+ SKILL.md 표준의 경우 전체 폴더).
/// </summary>
/// <returns>생성된 zip 파일 경로. 실패 시 null.</returns>
public static string? ExportSkill(SkillDefinition skill, string outputDir)
{
try
{
if (!File.Exists(skill.FilePath))
{
LogService.Warn($"스킬 내보내기 실패: 파일 없음 — {skill.FilePath}");
return null;
}
var zipName = $"{skill.Name}.skill.zip";
var zipPath = Path.Combine(outputDir, zipName);
// 기존 파일이 있으면 삭제
if (File.Exists(zipPath)) File.Delete(zipPath);
using var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create);
if (skill.IsStandardFormat)
{
// SKILL.md 표준: 전체 폴더를 zip에 추가
var skillDir = Path.GetDirectoryName(skill.FilePath);
if (skillDir != null && Directory.Exists(skillDir))
{
var baseName = Path.GetFileName(skillDir);
foreach (var file in Directory.EnumerateFiles(skillDir, "*", SearchOption.AllDirectories))
{
// 실행 가능 파일 제외
var ext = Path.GetExtension(file).ToLowerInvariant();
if (ext is ".exe" or ".dll" or ".bat" or ".cmd" or ".ps1" or ".sh") continue;
var entryName = baseName + "/" + Path.GetRelativePath(skillDir, file).Replace('\\', '/');
zip.CreateEntryFromFile(file, entryName, CompressionLevel.Optimal);
}
}
}
else
{
// *.skill.md 파일 단독
var entryName = $"{skill.Name}/{Path.GetFileName(skill.FilePath)}";
zip.CreateEntryFromFile(skill.FilePath, entryName, CompressionLevel.Optimal);
}
LogService.Info($"스킬 내보내기 완료: {zipPath}");
return zipPath;
}
catch (Exception ex)
{
LogService.Warn($"스킬 내보내기 실패: {ex.Message}");
return null;
}
}
/// <summary>
/// zip 파일에서 스킬을 가져옵니다.
/// zip 안의 .skill.md 또는 SKILL.md 파일을 사용자 스킬 폴더에 설치합니다.
/// </summary>
/// <returns>가져온 스킬 수. 0이면 실패.</returns>
public static int ImportSkills(string zipPath)
{
try
{
if (!File.Exists(zipPath))
{
LogService.Warn($"스킬 가져오기 실패: 파일 없음 — {zipPath}");
return 0;
}
var userFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "skills");
if (!Directory.Exists(userFolder))
Directory.CreateDirectory(userFolder);
using var zip = ZipFile.OpenRead(zipPath);
// 보안 검증: 실행 가능 파일 차단
var dangerousExts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ ".exe", ".dll", ".bat", ".cmd", ".ps1", ".sh", ".com", ".scr", ".msi" };
foreach (var entry in zip.Entries)
{
if (dangerousExts.Contains(Path.GetExtension(entry.Name)))
{
LogService.Warn($"스킬 가져오기 차단: 실행 가능 파일 포함 — {entry.FullName}");
return 0;
}
}
// 스킬 파일 존재 여부 확인
var skillEntries = zip.Entries
.Where(e => e.Name.EndsWith(".skill.md", StringComparison.OrdinalIgnoreCase)
|| e.Name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase))
.ToList();
if (skillEntries.Count == 0)
{
LogService.Warn("스킬 가져오기 실패: zip에 .skill.md 또는 SKILL.md 파일 없음");
return 0;
}
// zip 압축 해제
int importedCount = 0;
foreach (var entry in zip.Entries)
{
if (string.IsNullOrEmpty(entry.Name)) continue; // 디렉토리 항목 건너뛰기
// 상위 경로 이탈 방지
var relativePath = entry.FullName.Replace('/', Path.DirectorySeparatorChar);
if (relativePath.Contains("..")) continue;
var destPath = Path.Combine(userFolder, relativePath);
var destDir = Path.GetDirectoryName(destPath);
if (destDir != null && !Directory.Exists(destDir))
Directory.CreateDirectory(destDir);
entry.ExtractToFile(destPath, overwrite: true);
if (entry.Name.EndsWith(".skill.md", StringComparison.OrdinalIgnoreCase)
|| entry.Name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase))
importedCount++;
}
if (importedCount > 0)
{
LogService.Info($"스킬 가져오기 완료: {importedCount}개 스킬 ({zipPath})");
// 스킬 목록 리로드
LoadSkills();
}
return importedCount;
}
catch (Exception ex)
{
LogService.Warn($"스킬 가져오기 실패: {ex.Message}");
return 0;
}
}
// ─── 도구 이름 매핑 (외부 스킬 호환) ──────────────────────────────────────
/// <summary>
/// 외부 스킬(agentskills.io 등)의 도구 이름을 AX Copilot 내부 도구 이름으로 매핑합니다.
/// 스킬 시스템 프롬프트에서 외부 도구명을 내부 도구명으로 치환하여 호환성을 확보합니다.
/// </summary>
private static readonly Dictionary<string, string> ToolNameMap = new(StringComparer.OrdinalIgnoreCase)
{
// 범용 에이전트 표준 별칭
["Bash"] = "process",
["bash"] = "process",
["Read"] = "file_read",
["Write"] = "file_write",
["Edit"] = "file_edit",
["Glob"] = "glob",
["Grep"] = "grep",
["WebSearch"] = "http_tool",
["WebFetch"] = "http_tool",
["AskUserQuestion"] = "user_ask",
["LSP"] = "lsp_code_intel",
["ListMcpResourcesTool"] = "mcp_list_resources",
["ReadMcpResourceTool"] = "mcp_read_resource",
["Agent"] = "spawn_agent",
["Task"] = "spawn_agent",
["SendMessage"] = "notify_tool",
["PowerShell"] = "process",
// agentskills.io 표준
["execute_command"] = "process",
["read_file"] = "file_read",
["write_file"] = "file_write",
["edit_file"] = "file_edit",
["search_files"] = "glob",
["search_content"] = "grep",
["list_files"] = "folder_map",
// 기타 일반적 도구명
["shell"] = "process",
["terminal"] = "process",
["cat"] = "file_read",
["find"] = "glob",
["rg"] = "grep",
["grep_tool"] = "grep",
["git"] = "git_tool",
};
/// <summary>스킬 본문의 외부 도구 이름을 내부 도구 이름으로 매핑합니다.</summary>
public static string MapToolNames(string skillBody)
{
if (string.IsNullOrEmpty(skillBody)) return skillBody;
foreach (var kv in ToolNameMap)
{
// 코드 블록 내 도구 참조: `Bash`, `Read` 등을 `process`, `file_read`로 변환
skillBody = skillBody.Replace($"`{kv.Key}`", $"`{kv.Value}`");
// 괄호 내 참조: (Bash), (Read) 패턴
skillBody = skillBody.Replace($"({kv.Key})", $"({kv.Value})");
}
return skillBody;
}
// ─── 내부 메서드 ─────────────────────────────────────────────────────────
private static void CreateExampleSkill(string folder, string fileName, string name, string label, string description, string body)
{
var path = Path.Combine(folder, fileName);
if (File.Exists(path)) return;
var content = $"""
---
name: {name}
label: {label}
description: {description}
icon: \uE768
---
{body.Trim()}
""";
// 들여쓰기 정리 (raw string literal의 인덴트 제거)
var lines = content.Split('\n').Select(l => l.TrimStart()).ToArray();
File.WriteAllText(path, string.Join('\n', lines), Encoding.UTF8);
}
/// <summary>*.skill.md 파일을 파싱합니다.</summary>
private static SkillDefinition? ParseSkillFile(string filePath)
{
var content = File.ReadAllText(filePath, Encoding.UTF8);
if (!content.TrimStart().StartsWith("---"))
return null;
// 프론트매터 추출
var firstSep = content.IndexOf("---", StringComparison.Ordinal);
var secondSep = content.IndexOf("---", firstSep + 3, StringComparison.Ordinal);
if (secondSep < 0) return null;
var frontmatter = content[(firstSep + 3)..secondSep].Trim();
var body = content[(secondSep + 3)..].Trim();
// 키-값 파싱 (YAML 1단계 + metadata 맵 지원)
var meta = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string? currentMap = null;
foreach (var line in frontmatter.Split('\n'))
{
// 들여쓰기된 줄 → 현재 맵의 하위 키
if (currentMap != null && (line.StartsWith(" ") || line.StartsWith("\t")))
{
var trimmed = line.TrimStart();
if (trimmed.StartsWith("-"))
{
var item = trimmed[1..].Trim().Trim('"', '\'');
if (!string.IsNullOrEmpty(item))
{
var old = meta.GetValueOrDefault(currentMap, "");
meta[currentMap] = string.IsNullOrWhiteSpace(old) ? item : $"{old}, {item}";
}
continue;
}
var colonIdx = trimmed.IndexOf(':');
if (colonIdx > 0)
{
var subKey = trimmed[..colonIdx].Trim();
var subVal = trimmed[(colonIdx + 1)..].Trim().Trim('"', '\'');
if (string.IsNullOrEmpty(subVal))
{
// 중첩 맵 지원: hooks.file_edit.pre 형태로 확장
currentMap = $"{currentMap}.{subKey}";
}
else
{
meta[$"{currentMap}.{subKey}"] = subVal;
}
}
continue;
}
currentMap = null;
var ci = line.IndexOf(':');
if (ci > 0)
{
var key = line[..ci].Trim();
var value = line[(ci + 1)..].Trim().Trim('"', '\'');
if (string.IsNullOrEmpty(value))
{
// 빈 값 = 맵 시작 (metadata:)
currentMap = key;
}
else
{
meta[key] = value;
}
}
}
// 폴더명을 기본 이름으로 사용 (SKILL.md 표준: 폴더명 = name)
var dirName = Path.GetFileName(Path.GetDirectoryName(filePath) ?? "");
var fileName = Path.GetFileNameWithoutExtension(filePath).Replace(".skill", "");
var fallbackName = filePath.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase) ? dirName : fileName;
var name = meta.GetValueOrDefault("name", fallbackName);
if (string.IsNullOrEmpty(name)) return null;
// SKILL.md 표준: label/icon은 metadata 맵에 있을 수 있음
var label = meta.GetValueOrDefault("label", "") ?? "";
var icon = meta.GetValueOrDefault("icon", "") ?? "";
// metadata.label / metadata.icon 지원 (SKILL.md 표준)
if (string.IsNullOrEmpty(label) && meta.TryGetValue("metadata.label", out var ml))
label = ml ?? "";
if (string.IsNullOrEmpty(icon) && meta.TryGetValue("metadata.icon", out var mi))
icon = mi ?? "";
var (hooks, hookFilters) = BuildHookPolicy(meta);
return new SkillDefinition
{
Id = name,
Name = name,
Label = string.IsNullOrEmpty(label) ? name : label,
Description = meta.GetValueOrDefault("description", "") ?? "",
Icon = string.IsNullOrEmpty(icon) ? "\uE768" : ConvertUnicodeEscape(icon),
SystemPrompt = MapToolNames(body),
FilePath = filePath,
License = meta.GetValueOrDefault("license", "") ?? "",
Compatibility = meta.GetValueOrDefault("compatibility", "") ?? "",
AllowedTools = meta.GetValueOrDefault("allowed-tools", "") ?? "",
Requires = meta.GetValueOrDefault("requires", "") ?? "",
Tabs = meta.GetValueOrDefault("tabs", "all") ?? "all",
WhenToUse = meta.GetValueOrDefault("when_to_use", "") ?? "",
ArgumentHint = meta.GetValueOrDefault("argument-hint", "") ?? "",
Model = meta.GetValueOrDefault("model", "") ?? "",
DisableModelInvocation = ParseBooleanMeta(meta.GetValueOrDefault("disable-model-invocation", "")),
UserInvocable = !meta.ContainsKey("user-invocable") || ParseBooleanMeta(meta.GetValueOrDefault("user-invocable", "true")),
ExecutionContext = meta.GetValueOrDefault("context", "") ?? "",
Agent = meta.GetValueOrDefault("agent", "") ?? "",
Effort = meta.GetValueOrDefault("effort", "") ?? "",
Paths = meta.GetValueOrDefault("paths", "") ?? "",
Shell = meta.GetValueOrDefault("shell", "") ?? "",
Hooks = hooks,
HookFilters = hookFilters,
IsSample = ParseBooleanMeta(meta.GetValueOrDefault("sample", ""))
|| ParseBooleanMeta(meta.GetValueOrDefault("metadata.sample", "")),
};
}
private static bool ParseBooleanMeta(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
return value.Trim().ToLowerInvariant() switch
{
"true" or "1" or "yes" or "y" => true,
_ => false,
};
}
private static bool IsSkillInvocableNow(SkillDefinition skill)
{
if (!skill.UserInvocable) return false;
if (!skill.IsAvailable) return false;
if (string.IsNullOrWhiteSpace(skill.Paths)) return true;
return _activeConditionalSkillNames.Contains(skill.Name);
}
private static string NormalizeEffort(string raw)
{
var effort = raw.Trim().ToLowerInvariant();
return effort switch
{
"low" or "medium" or "high" or "xhigh" => effort,
_ => raw.Trim(),
};
}
private static List<string> ParseAllowedToolsForRuntimePolicy(string raw)
{
if (string.IsNullOrWhiteSpace(raw))
return new List<string>();
var tools = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var token in raw.Split([',', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var normalized = token.Trim().Trim('`', '"', '\'');
if (string.IsNullOrWhiteSpace(normalized))
continue;
if (ToolNameMap.TryGetValue(normalized, out var mapped))
normalized = mapped;
tools.Add(normalized);
}
return tools.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList();
}
private static List<string> ParseHookNamesForRuntimePolicy(string raw)
{
if (string.IsNullOrWhiteSpace(raw))
return new List<string>();
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var token in raw.Split([',', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var normalized = token.Trim().Trim('`', '"', '\'');
if (!string.IsNullOrWhiteSpace(normalized))
names.Add(normalized);
}
return names.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
}
private static List<string> ParsePathPatterns(string raw)
{
return raw
.Split([',', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(p => p.Trim().Trim('"', '\''))
.Where(p => !string.IsNullOrWhiteSpace(p) && p != "**")
.Select(NormalizePathForMatch)
.ToList();
}
private static (string Hooks, string HookFilters) BuildHookPolicy(Dictionary<string, string> meta)
{
var hooks = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var filters = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
AddHookTokens(hooks, meta.GetValueOrDefault("hooks", ""));
foreach (var hook in hooks)
filters.Add($"{hook}@*@*");
foreach (var kv in meta.Where(kv => kv.Key.StartsWith("hooks.", StringComparison.OrdinalIgnoreCase)))
{
var suffix = kv.Key["hooks.".Length..].Trim();
var keyFilter = ParseHookKeyFilter(suffix);
if (!string.IsNullOrWhiteSpace(keyFilter))
filters.Add(keyFilter);
if (!string.IsNullOrWhiteSpace(suffix)
&& ExtractTimingFromSuffix(suffix) == null)
{
hooks.Add(suffix);
}
foreach (var hookName in ParseHookTokens(kv.Value))
{
hooks.Add(hookName);
var timing = ExtractTimingFromSuffix(suffix) ?? "*";
var tool = ExtractToolFromSuffix(suffix) ?? "*";
filters.Add($"{hookName}@{timing}@{tool}");
}
}
return (
string.Join(", ", hooks.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)),
string.Join(", ", filters.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
);
}
private static void AddHookTokens(HashSet<string> target, string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return;
foreach (var token in ParseHookTokens(raw))
target.Add(token);
}
private static IEnumerable<string> ParseHookTokens(string raw)
{
foreach (var token in raw.Split([',', ';', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var normalized = token.Trim().Trim('`', '"', '\'');
if (!string.IsNullOrWhiteSpace(normalized))
yield return normalized;
}
}
private static string? ParseHookKeyFilter(string suffix)
{
var timing = ExtractTimingFromSuffix(suffix);
if (string.IsNullOrWhiteSpace(timing))
return null;
var tool = ExtractToolFromSuffix(suffix) ?? "*";
return $"*@{timing}@{tool}";
}
private static string? ExtractTimingFromSuffix(string suffix)
{
var normalized = suffix.Trim().ToLowerInvariant();
if (normalized is "pre" or "pretooluse")
return "pre";
if (normalized is "post" or "posttooluse")
return "post";
if (normalized.EndsWith(".pre") || normalized.EndsWith(".pretooluse"))
return "pre";
if (normalized.EndsWith(".post") || normalized.EndsWith(".posttooluse"))
return "post";
return null;
}
private static string? ExtractToolFromSuffix(string suffix)
{
var trimmed = suffix.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
return null;
var dotIndex = trimmed.LastIndexOf('.');
if (dotIndex <= 0)
return null;
return trimmed[..dotIndex].Trim();
}
private static string? ToRelativePathIfUnderCwd(string filePath, string cwd)
{
try
{
var fullCwd = Path.GetFullPath(cwd);
var fullFile = Path.GetFullPath(filePath);
if (!fullFile.StartsWith(fullCwd, StringComparison.OrdinalIgnoreCase))
return null;
var rel = Path.GetRelativePath(fullCwd, fullFile);
if (string.IsNullOrWhiteSpace(rel) || rel.StartsWith(".."))
return null;
return NormalizePathForMatch(rel);
}
catch
{
return null;
}
}
private static bool PathMatchesPatterns(string relativePath, List<string> patterns)
{
foreach (var pattern in patterns)
{
if (GlobMatch(relativePath, pattern))
return true;
}
return false;
}
private static bool GlobMatch(string path, string pattern)
{
var p = path.Replace('\\', '/');
var g = pattern.Replace('\\', '/');
// 디렉터리 표기 관용 지원: "src/" -> "src/**"
if (g.EndsWith('/')) g += "**";
var regex = "^" + System.Text.RegularExpressions.Regex
.Escape(g)
.Replace(@"\*\*", ".*")
.Replace(@"\*", @"[^/]*")
.Replace(@"\?", @"[^/]") + "$";
return System.Text.RegularExpressions.Regex.IsMatch(
p,
regex,
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
}
private static string NormalizePathForMatch(string path) =>
path.Replace('\\', '/').Trim();
/// <summary>YAML의 \uXXXX 이스케이프를 실제 유니코드 문자로 변환합니다.</summary>
private static string ConvertUnicodeEscape(string value)
{
if (string.IsNullOrEmpty(value)) return value;
return System.Text.RegularExpressions.Regex.Replace(
value, @"\\u([0-9a-fA-F]{4})",
m => ((char)Convert.ToInt32(m.Groups[1].Value, 16)).ToString());
}
}
/// <summary>스킬 정의 (*.skill.md에서 로드).</summary>
public class SkillDefinition
{
public string Id { get; init; } = "";
public string Name { get; init; } = "";
public string Label { get; init; } = "";
public string Description { get; init; } = "";
public string Icon { get; init; } = "\uE768";
public string SystemPrompt { get; init; } = "";
public string FilePath { get; init; } = "";
// SKILL.md 표준 확장 필드
public string License { get; init; } = "";
public string Compatibility { get; init; } = "";
public string AllowedTools { get; init; } = "";
/// <summary>런타임 의존성. "python", "node", "python,node" 등. 빈 문자열이면 의존성 없음.</summary>
public string Requires { get; init; } = "";
/// <summary>표시 대상 탭. "all"=전체, "cowork"=코워크만, "code"=코드만. 쉼표 구분 가능.</summary>
public string Tabs { get; init; } = "all";
/// <summary>스킬 사용 추천 조건 설명.</summary>
public string WhenToUse { get; init; } = "";
/// <summary>슬래시 호출 시 인자 힌트 문자열.</summary>
public string ArgumentHint { get; init; } = "";
/// <summary>스킬 고정 모델 (선택).</summary>
public string Model { get; init; } = "";
/// <summary>모델 호출 비활성화 여부.</summary>
public bool DisableModelInvocation { get; init; }
/// <summary>사용자가 직접 호출 가능한지 여부.</summary>
public bool UserInvocable { get; init; } = true;
/// <summary>실행 컨텍스트 힌트 (예: inline, fork).</summary>
public string ExecutionContext { get; init; } = "";
/// <summary>지정 에이전트 힌트.</summary>
public string Agent { get; init; } = "";
/// <summary>추론 강도 힌트.</summary>
public string Effort { get; init; } = "";
/// <summary>조건부 활성화 경로 패턴 원문.</summary>
public string Paths { get; init; } = "";
/// <summary>쉘 실행 옵션 힌트.</summary>
public string Shell { get; init; } = "";
/// <summary>스킬 전용 훅 이름 목록(쉼표 구분).</summary>
public string Hooks { get; init; } = "";
/// <summary>스킬 훅 필터 규칙(형식: hook@timing@tool, 쉼표 구분).</summary>
public string HookFilters { get; init; } = "";
/// <summary>갤러리에서 예제 스킬로 표시할지 여부.</summary>
public bool IsSample { get; init; }
/// <summary>런타임 의존성 충족 여부. Requires가 비어있으면 항상 true.</summary>
public bool IsAvailable { get; set; } = true;
/// <summary>지정 탭에서 이 스킬을 표시할지 판정합니다.</summary>
public bool IsVisibleInTab(string activeTab)
{
if (string.IsNullOrEmpty(Tabs) || Tabs.Equals("all", StringComparison.OrdinalIgnoreCase))
return true;
var tabs = Tabs.Split(',').Select(t => t.Trim().ToLowerInvariant());
var tab = activeTab.ToLowerInvariant();
return tabs.Any(t => t == "all" || t == tab);
}
/// <summary>SKILL.md 표준 폴더 형식인지 여부.</summary>
public bool IsStandardFormat => FilePath.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase);
/// <summary>비가용 시 사용자에게 표시할 힌트 메시지.</summary>
public string UnavailableHint
{
get
{
if (IsAvailable || string.IsNullOrEmpty(Requires)) return "";
var runtimes = Requires.Split(',').Select(r => r.Trim());
var missing = runtimes.Where(r => !RuntimeDetector.IsAvailable(r)).ToArray();
return missing.Length > 0 ? $"({string.Join(", ", missing.Select(r => char.ToUpper(r[0]) + r[1..]))} 필요)" : "";
}
}
}

View File

@@ -0,0 +1,246 @@
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// C#/Python/JavaScript 코드 스니펫을 즉시 실행하는 도구.
/// 임시 파일에 코드를 저장하고 해당 런타임으로 실행합니다.
/// </summary>
public class SnippetRunnerTool : IAgentTool
{
public string Name => "snippet_runner";
public string Description =>
"Execute a code snippet in C#, Python, or JavaScript. " +
"Writes the code to a temp file, runs it, and returns stdout/stderr. " +
"Useful for quick calculations, data transformations, format conversions, and testing algorithms. " +
"Max execution time: 30 seconds. Max output: 8000 chars.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["language"] = new()
{
Type = "string",
Description = "Programming language: 'csharp', 'python', or 'javascript'",
Enum = ["csharp", "python", "javascript"],
},
["code"] = new()
{
Type = "string",
Description = "Source code to execute. For C#: top-level statements or full Program class. " +
"For Python: standard script. For JavaScript: Node.js script.",
},
["timeout"] = new()
{
Type = "integer",
Description = "Timeout in seconds. Default: 30, max: 60.",
},
},
Required = ["language", "code"],
};
// 위험한 코드 패턴 차단
private static readonly string[] DangerousPatterns =
[
"Process.Start", "ProcessStartInfo",
"Registry.", "RegistryKey",
"Environment.Exit",
"File.Delete", "Directory.Delete",
"Format-Volume", "Remove-Item",
"os.remove", "os.rmdir", "shutil.rmtree",
"subprocess.call", "subprocess.Popen",
"child_process", "require('fs').unlink",
"exec(", "eval(",
];
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var language = args.GetProperty("language").GetString() ?? "";
var code = args.TryGetProperty("code", out var c) ? c.GetString() ?? "" : "";
var timeout = args.TryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 60) : 30;
if (string.IsNullOrWhiteSpace(code))
return ToolResult.Fail("code가 비어 있습니다.");
// 위험 패턴 검사
foreach (var pattern in DangerousPatterns)
{
if (code.Contains(pattern, StringComparison.OrdinalIgnoreCase))
return ToolResult.Fail($"보안 차단: '{pattern}' 패턴은 snippet_runner에서 허용되지 않습니다. process 도구를 사용하세요.");
}
// 권한 확인
if (!await context.CheckWritePermissionAsync(Name, $"[{language}] {(code.Length > 100 ? code[..100] + "..." : code)}"))
return ToolResult.Fail("코드 실행 권한 거부");
// 설정에서 비활성화 여부 확인
var app = System.Windows.Application.Current as App;
var enabled = app?.SettingsService?.Settings?.Llm?.Code?.EnableSnippetRunner ?? true;
if (!enabled)
return ToolResult.Fail("snippet_runner가 설정에서 비활성화되어 있습니다. 설정 → AX Agent → 기능에서 활성화하세요.");
return language switch
{
"csharp" => await RunCSharpAsync(code, timeout, context, ct),
"python" => await RunScriptAsync("python", ".py", code, timeout, context, ct),
"javascript" => await RunScriptAsync("node", ".js", code, timeout, context, ct),
_ => ToolResult.Fail($"지원하지 않는 언어: {language}. csharp, python, javascript 중 선택하세요."),
};
}
private async Task<ToolResult> RunCSharpAsync(string code, int timeout, AgentContext context, CancellationToken ct)
{
// dotnet-script 사용 시도, 없으면 dotnet run 임시 프로젝트
var tempDir = Path.Combine(Path.GetTempPath(), $"axcopilot_snippet_{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
// dotnet-script 먼저 확인
if (await IsToolAvailableAsync("dotnet-script"))
{
var scriptFile = Path.Combine(tempDir, "snippet.csx");
await File.WriteAllTextAsync(scriptFile, code, ct);
return await RunProcessAsync("dotnet-script", scriptFile, timeout, tempDir, ct);
}
// dotnet run으로 폴백 — 임시 콘솔 프로젝트 생성
var csprojContent = """
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
""";
await File.WriteAllTextAsync(Path.Combine(tempDir, "Snippet.csproj"), csprojContent, ct);
// top-level statements 코드를 Program.cs로
var programCode = code.Contains("class ") && code.Contains("static void Main")
? code // 이미 전체 클래스
: code; // top-level statements (net8.0 지원)
await File.WriteAllTextAsync(Path.Combine(tempDir, "Program.cs"), programCode, ct);
return await RunProcessAsync("dotnet", $"run --project \"{tempDir}\"", timeout, tempDir, ct);
}
finally
{
// 정리
try { Directory.Delete(tempDir, true); } catch { }
}
}
private async Task<ToolResult> RunScriptAsync(string runtime, string extension, string code,
int timeout, AgentContext context, CancellationToken ct)
{
// 런타임 사용 가능 확인
if (!await IsToolAvailableAsync(runtime))
return ToolResult.Fail($"{runtime}이 설치되지 않았습니다. 시스템에 {runtime}을 설치하세요.");
var tempFile = Path.Combine(Path.GetTempPath(), $"axcopilot_snippet_{Guid.NewGuid():N}{extension}");
try
{
await File.WriteAllTextAsync(tempFile, code, Encoding.UTF8, ct);
return await RunProcessAsync(runtime, $"\"{tempFile}\"", timeout, context.WorkFolder, ct);
}
finally
{
try { File.Delete(tempFile); } catch { }
}
}
private static async Task<ToolResult> RunProcessAsync(string fileName, string arguments,
int timeout, string workDir, CancellationToken ct)
{
var psi = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
if (!string.IsNullOrEmpty(workDir) && Directory.Exists(workDir))
psi.WorkingDirectory = workDir;
using var process = new Process { StartInfo = psi };
var stdout = new StringBuilder();
var stderr = new StringBuilder();
process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); };
try
{
process.Start();
}
catch (Exception ex)
{
return ToolResult.Fail($"실행 실패: {ex.Message}");
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
try
{
await process.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
try { process.Kill(entireProcessTree: true); } catch { }
return ToolResult.Fail($"실행 타임아웃 ({timeout}초 초과)");
}
var output = stdout.ToString().TrimEnd();
var error = stderr.ToString().TrimEnd();
// 출력 크기 제한
if (output.Length > 8000)
output = output[..8000] + "\n... (출력 잘림)";
var result = new StringBuilder();
result.AppendLine($"[Exit code: {process.ExitCode}]");
if (!string.IsNullOrEmpty(output))
result.AppendLine(output);
if (!string.IsNullOrEmpty(error))
result.AppendLine($"[stderr]\n{error}");
return ToolResult.Ok(result.ToString());
}
private static async Task<bool> IsToolAvailableAsync(string tool)
{
try
{
var psi = new ProcessStartInfo
{
FileName = Environment.OSVersion.Platform == PlatformID.Win32NT ? "where" : "which",
Arguments = tool,
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true,
};
using var process = Process.Start(psi);
if (process == null) return false;
await process.WaitForExitAsync();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,210 @@
using System.IO;
using System.Text;
using System.Text.Json;
using Microsoft.Data.Sqlite;
namespace AxCopilot.Services.Agent;
/// <summary>
/// SQLite 데이터베이스 쿼리 실행 도구.
/// 로컬 .db/.sqlite 파일에 대해 SELECT/INSERT/UPDATE/DELETE 쿼리를 실행합니다.
/// </summary>
public class SqlTool : IAgentTool
{
public string Name => "sql_tool";
public string Description =>
"Execute SQL queries on local SQLite database files. Actions: " +
"'query' — run SELECT query and return results as table; " +
"'execute' — run INSERT/UPDATE/DELETE and return affected rows; " +
"'schema' — show database schema (tables, columns, types); " +
"'tables' — list all tables in the database.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "Action to perform",
Enum = ["query", "execute", "schema", "tables"],
},
["db_path"] = new()
{
Type = "string",
Description = "Path to SQLite database file (.db, .sqlite, .sqlite3)",
},
["sql"] = new()
{
Type = "string",
Description = "SQL query to execute (for query/execute actions)",
},
["max_rows"] = new()
{
Type = "string",
Description = "Maximum rows to return (default: 100, max: 1000)",
},
},
Required = ["action", "db_path"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var dbPath = args.GetProperty("db_path").GetString() ?? "";
if (!Path.IsPathRooted(dbPath))
dbPath = Path.Combine(context.WorkFolder, dbPath);
if (!File.Exists(dbPath))
return Task.FromResult(ToolResult.Fail($"Database file not found: {dbPath}"));
try
{
var connStr = $"Data Source={dbPath};Mode=ReadOnly";
// execute 액션은 ReadWrite 필요
if (action == "execute")
connStr = $"Data Source={dbPath}";
using var conn = new SqliteConnection(connStr);
conn.Open();
return Task.FromResult(action switch
{
"query" => QueryAction(conn, args),
"execute" => ExecuteAction(conn, args),
"schema" => SchemaAction(conn),
"tables" => TablesAction(conn),
_ => ToolResult.Fail($"Unknown action: {action}"),
});
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"SQL 오류: {ex.Message}"));
}
}
private static ToolResult QueryAction(SqliteConnection conn, JsonElement args)
{
if (!args.TryGetProperty("sql", out var sqlProp))
return ToolResult.Fail("'sql' parameter is required for query action");
var sql = sqlProp.GetString() ?? "";
// SELECT만 허용
if (!sql.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase) &&
!sql.TrimStart().StartsWith("WITH", StringComparison.OrdinalIgnoreCase) &&
!sql.TrimStart().StartsWith("PRAGMA", StringComparison.OrdinalIgnoreCase))
return ToolResult.Fail("Query action only allows SELECT/WITH/PRAGMA statements. Use 'execute' for modifications.");
var maxRows = args.TryGetProperty("max_rows", out var mr) && int.TryParse(mr.GetString(), out var mrv)
? Math.Min(mrv, 1000) : 100;
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
using var reader = cmd.ExecuteReader();
var sb = new StringBuilder();
var colCount = reader.FieldCount;
// 헤더
var colNames = new string[colCount];
for (var i = 0; i < colCount; i++)
colNames[i] = reader.GetName(i);
sb.AppendLine(string.Join(" | ", colNames));
sb.AppendLine(new string('-', colNames.Sum(c => c.Length + 3)));
// 행
var rowCount = 0;
while (reader.Read() && rowCount < maxRows)
{
var values = new string[colCount];
for (var i = 0; i < colCount; i++)
values[i] = reader.IsDBNull(i) ? "NULL" : reader.GetValue(i)?.ToString() ?? "";
sb.AppendLine(string.Join(" | ", values));
rowCount++;
}
if (rowCount == 0)
return ToolResult.Ok("Query returned 0 rows.");
var result = sb.ToString();
if (result.Length > 8000) result = result[..8000] + "\n... (truncated)";
return ToolResult.Ok($"Rows: {rowCount}" + (rowCount >= maxRows ? $" (limited to {maxRows})" : "") + $"\n\n{result}");
}
private static ToolResult ExecuteAction(SqliteConnection conn, JsonElement args)
{
if (!args.TryGetProperty("sql", out var sqlProp))
return ToolResult.Fail("'sql' parameter is required for execute action");
var sql = sqlProp.GetString() ?? "";
// DDL/DML만 허용 (DROP DATABASE 등 위험 명령 차단)
var trimmed = sql.TrimStart().ToUpperInvariant();
if (trimmed.StartsWith("DROP DATABASE") || trimmed.StartsWith("ATTACH") || trimmed.StartsWith("DETACH"))
return ToolResult.Fail("Security: DROP DATABASE, ATTACH, DETACH are not allowed.");
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
var affected = cmd.ExecuteNonQuery();
return ToolResult.Ok($"✓ {affected} row(s) affected");
}
private static ToolResult SchemaAction(SqliteConnection conn)
{
var sb = new StringBuilder();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name";
using var reader = cmd.ExecuteReader();
var tables = new List<string>();
while (reader.Read()) tables.Add(reader.GetString(0));
reader.Close();
foreach (var table in tables)
{
sb.AppendLine($"## {table}");
using var pragmaCmd = conn.CreateCommand();
pragmaCmd.CommandText = $"PRAGMA table_info(\"{table}\")";
using var pragmaReader = pragmaCmd.ExecuteReader();
sb.AppendLine($"{"#",-4} {"Name",-25} {"Type",-15} {"NotNull",-8} {"Default",-15} {"PK"}");
while (pragmaReader.Read())
{
sb.AppendLine($"{pragmaReader.GetInt32(0),-4} " +
$"{pragmaReader.GetString(1),-25} " +
$"{pragmaReader.GetString(2),-15} " +
$"{(pragmaReader.GetInt32(3) == 1 ? "YES" : ""),-8} " +
$"{(pragmaReader.IsDBNull(4) ? "" : pragmaReader.GetString(4)),-15} " +
$"{(pragmaReader.GetInt32(5) > 0 ? "PK" : "")}");
}
pragmaReader.Close();
sb.AppendLine();
}
return ToolResult.Ok(sb.ToString());
}
private static ToolResult TablesAction(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
SELECT m.name, m.type,
(SELECT count(*) FROM pragma_table_info(m.name)) as col_count
FROM sqlite_master m
WHERE m.type IN ('table','view')
ORDER BY m.type, m.name";
using var reader = cmd.ExecuteReader();
var sb = new StringBuilder();
sb.AppendLine($"{"Name",-30} {"Type",-8} {"Columns"}");
sb.AppendLine(new string('-', 50));
var count = 0;
while (reader.Read())
{
sb.AppendLine($"{reader.GetString(0),-30} {reader.GetString(1),-8} {reader.GetInt32(2)}");
count++;
}
return ToolResult.Ok($"Found {count} tables/views:\n\n{sb}");
}
}

View File

@@ -0,0 +1,498 @@
using System.Text;
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Spawns a background sub-agent that runs an isolated read-only agent loop.
/// The parent agent can later collect all finished results with wait_agents.
/// </summary>
public class SubAgentTool : IAgentTool
{
public static event Action<SubAgentStatusEvent>? StatusChanged;
public string Name => "spawn_agent";
public string Description =>
"Create a read-only sub-agent for bounded parallel research or codebase analysis.\n" +
"Use this when a side task can run independently while the main agent continues.\n" +
"The sub-agent can inspect files, search code, review diffs, and summarize findings.\n" +
"Collect results later with wait_agents.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["task"] = new ToolProperty
{
Type = "string",
Description = "The self-contained task for the sub-agent."
},
["id"] = new ToolProperty
{
Type = "string",
Description = "A unique sub-agent identifier used by wait_agents."
},
},
Required = new() { "task", "id" }
};
private static readonly Dictionary<string, SubAgentTask> _activeTasks = new();
private static readonly object _lock = new();
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var task = args.TryGetProperty("task", out var t) ? t.GetString() ?? "" : "";
var id = args.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(task) || string.IsNullOrWhiteSpace(id))
return Task.FromResult(ToolResult.Fail("task and id are required."));
CleanupStale();
var app = System.Windows.Application.Current as App;
var maxAgents = app?.SettingsService?.Settings.Llm.MaxSubAgents ?? 3;
lock (_lock)
{
if (_activeTasks.ContainsKey(id))
return Task.FromResult(ToolResult.Fail($"Sub-agent id already exists: {id}"));
var running = _activeTasks.Values.Count(x => x.CompletedAt == null);
if (running >= maxAgents)
return Task.FromResult(ToolResult.Fail($"Maximum concurrent sub-agents reached ({maxAgents})."));
}
var subTask = new SubAgentTask
{
Id = id,
Task = task,
StartedAt = DateTime.Now,
};
subTask.RunTask = System.Threading.Tasks.Task.Run(async () =>
{
try
{
var result = await RunSubAgentAsync(id, task, context).ConfigureAwait(false);
subTask.Result = result;
subTask.Success = true;
NotifyStatus(new SubAgentStatusEvent
{
Id = id,
Task = task,
Status = SubAgentRunStatus.Completed,
Summary = $"Sub-agent '{id}' completed.",
Result = result,
Timestamp = DateTime.Now,
});
}
catch (Exception ex)
{
subTask.Result = $"Error: {ex.Message}";
subTask.Success = false;
NotifyStatus(new SubAgentStatusEvent
{
Id = id,
Task = task,
Status = SubAgentRunStatus.Failed,
Summary = $"Sub-agent '{id}' failed: {ex.Message}",
Result = subTask.Result,
Timestamp = DateTime.Now,
});
}
finally
{
subTask.CompletedAt = DateTime.Now;
}
}, CancellationToken.None);
lock (_lock)
_activeTasks[id] = subTask;
NotifyStatus(new SubAgentStatusEvent
{
Id = id,
Task = task,
Status = SubAgentRunStatus.Started,
Summary = $"Sub-agent '{id}' started.",
Timestamp = DateTime.Now,
});
return Task.FromResult(ToolResult.Ok(
$"Sub-agent '{id}' started.\nTask: {task}\nUse wait_agents later to collect the result."));
}
private static async Task<string> RunSubAgentAsync(string id, string task, AgentContext parentContext)
{
var settings = CreateSubAgentSettings(parentContext);
using var llm = new LlmService(settings);
using var tools = await CreateSubAgentRegistryAsync(settings).ConfigureAwait(false);
var loop = new AgentLoopService(llm, tools, settings)
{
ActiveTab = parentContext.ActiveTab,
};
var messages = new List<ChatMessage>
{
new()
{
Role = "system",
Content = BuildSubAgentSystemPrompt(task, parentContext),
},
new()
{
Role = "user",
Content = task,
}
};
var finalText = await loop.RunAsync(messages, CancellationToken.None).ConfigureAwait(false);
var eventSummary = SummarizeEvents(loop.Events);
var sb = new StringBuilder();
sb.AppendLine($"[Sub-agent {id}]");
sb.AppendLine($"Task: {task}");
if (!string.IsNullOrWhiteSpace(parentContext.WorkFolder))
sb.AppendLine($"Work folder: {parentContext.WorkFolder}");
if (!string.IsNullOrWhiteSpace(eventSummary))
{
sb.AppendLine();
sb.AppendLine("Observed work:");
sb.AppendLine(eventSummary);
}
sb.AppendLine();
sb.AppendLine("Result:");
sb.AppendLine(string.IsNullOrWhiteSpace(finalText) ? "(empty)" : finalText.Trim());
return sb.ToString().TrimEnd();
}
private static SettingsService CreateSubAgentSettings(AgentContext parentContext)
{
var settings = new SettingsService();
settings.Load();
var llm = settings.Settings.Llm;
llm.WorkFolder = parentContext.WorkFolder;
llm.FilePermission = "Deny";
llm.PlanMode = "off";
llm.AgentHooks = new();
llm.ToolPermissions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
llm.DisabledTools = new List<string>
{
"spawn_agent",
"wait_agents",
"file_write",
"file_edit",
"process",
"build_run",
"snippet_runner",
"memory",
"notify",
"open_external",
"user_ask",
"checkpoint",
"diff_preview",
"playbook",
"http_tool",
"clipboard",
"sql_tool",
};
return settings;
}
private static async Task<ToolRegistry> CreateSubAgentRegistryAsync(SettingsService settings)
{
var registry = new ToolRegistry();
registry.Register(new FileReadTool());
registry.Register(new GlobTool());
registry.Register(new GrepTool());
registry.Register(new FolderMapTool());
registry.Register(new DocumentReaderTool());
registry.Register(new DevEnvDetectTool());
registry.Register(new GitTool());
registry.Register(new LspTool());
registry.Register(new CodeSearchTool());
registry.Register(new CodeReviewTool());
registry.Register(new ProjectRuleTool());
registry.Register(new SkillManagerTool());
registry.Register(new JsonTool());
registry.Register(new RegexTool());
registry.Register(new DiffTool());
registry.Register(new Base64Tool());
registry.Register(new HashTool());
registry.Register(new DateTimeTool());
registry.Register(new MathTool());
registry.Register(new XmlTool());
registry.Register(new MultiReadTool());
registry.Register(new FileInfoTool());
registry.Register(new DocumentReviewTool());
await registry.RegisterMcpToolsAsync(settings.Settings.Llm.McpServers).ConfigureAwait(false);
return registry;
}
private static string BuildSubAgentSystemPrompt(string task, AgentContext parentContext)
{
var sb = new StringBuilder();
sb.AppendLine("You are a focused sub-agent for AX Copilot.");
sb.AppendLine("You are running a bounded, read-only investigation.");
sb.AppendLine("Use tools to inspect the project, gather evidence, and produce an actionable result.");
sb.AppendLine("Do not ask the user questions.");
sb.AppendLine("Do not attempt file edits, command execution, notifications, or external side effects.");
sb.AppendLine("Prefer direct evidence from files and tool results over speculation.");
sb.AppendLine("If something is uncertain, say so briefly and identify what evidence is missing.");
if (!string.IsNullOrWhiteSpace(parentContext.WorkFolder))
sb.AppendLine($"Current work folder: {parentContext.WorkFolder}");
if (!string.IsNullOrWhiteSpace(parentContext.ActiveTab))
sb.AppendLine($"Current tab: {parentContext.ActiveTab}");
sb.AppendLine();
sb.AppendLine("Investigation rules:");
sb.AppendLine("1. Start by reading the directly relevant files, not by summarizing from memory.");
sb.AppendLine("2. If the task mentions a code path, type, method, or feature, use grep/glob to find references and callers.");
sb.AppendLine("3. If the task is about bugs, trace likely cause -> affected files -> validation evidence.");
sb.AppendLine("4. If the task is about implementation planning, identify the minimum file set and the main risk.");
sb.AppendLine("5. If the task is about review, prioritize concrete defects, regressions, and missing tests.");
var workflowHints = BuildSubAgentWorkflowHints(task, parentContext.ActiveTab);
if (!string.IsNullOrWhiteSpace(workflowHints))
{
sb.AppendLine();
sb.AppendLine("Task-specific guidance:");
sb.AppendLine(workflowHints);
}
sb.AppendLine("Final answer format:");
sb.AppendLine("1. Short conclusion");
sb.AppendLine("2. Files checked");
sb.AppendLine("3. Key evidence");
sb.AppendLine("4. Recommended next action for the main agent");
sb.AppendLine("5. Risks or unknowns");
return sb.ToString().TrimEnd();
}
private static string BuildSubAgentWorkflowHints(string task, string? activeTab)
{
var normalizedTask = task ?? "";
var sb = new StringBuilder();
if (normalizedTask.Contains("review", StringComparison.OrdinalIgnoreCase)
|| normalizedTask.Contains("검토", StringComparison.OrdinalIgnoreCase)
|| normalizedTask.Contains("리뷰", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine("- Review tasks should name the concrete issue first, then cite the supporting file evidence.");
sb.AppendLine("- Mention missing or weak tests when behavior could regress.");
}
if (normalizedTask.Contains("bug", StringComparison.OrdinalIgnoreCase)
|| normalizedTask.Contains("error", StringComparison.OrdinalIgnoreCase)
|| normalizedTask.Contains("실패", StringComparison.OrdinalIgnoreCase)
|| normalizedTask.Contains("오류", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine("- Bug investigations should identify the most likely root cause and the exact files that support that conclusion.");
sb.AppendLine("- Suggest the smallest safe fix path for the main agent.");
}
if (string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine("- For code tasks, always mention impacted callers/references if you found any.");
sb.AppendLine("- Call out related tests or note explicitly when tests were not found.");
}
if (normalizedTask.Contains("plan", StringComparison.OrdinalIgnoreCase)
|| normalizedTask.Contains("설계", StringComparison.OrdinalIgnoreCase)
|| normalizedTask.Contains("계획", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine("- Planning tasks should identify the minimum file set, order of work, and the primary validation step.");
}
return sb.ToString().TrimEnd();
}
private static string SummarizeEvents(IEnumerable<AgentEvent> events)
{
var lines = events
.Where(e => e.Type is AgentEventType.ToolCall or AgentEventType.ToolResult or AgentEventType.StepStart)
.TakeLast(12)
.Select(e =>
{
var label = e.Type switch
{
AgentEventType.ToolCall => $"tool:{e.ToolName}",
AgentEventType.ToolResult => $"result:{e.ToolName}",
AgentEventType.StepStart => "step",
_ => e.Type.ToString().ToLowerInvariant()
};
var summary = string.IsNullOrWhiteSpace(e.Summary) ? "" : $" - {e.Summary.Trim()}";
return $"- {label}{summary}";
})
.ToList();
return lines.Count == 0 ? "" : string.Join(Environment.NewLine, lines);
}
public static IReadOnlyDictionary<string, SubAgentTask> ActiveTasks
{
get
{
lock (_lock)
return new Dictionary<string, SubAgentTask>(_activeTasks);
}
}
public static async Task<string> WaitAsync(
IEnumerable<string>? ids = null,
bool completedOnly = false,
CancellationToken ct = default)
{
List<SubAgentTask> tasks;
lock (_lock)
tasks = _activeTasks.Values.ToList();
var requestedIds = ids?
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (requestedIds is { Count: > 0 })
tasks = tasks
.Where(t => requestedIds.Contains(t.Id, StringComparer.OrdinalIgnoreCase))
.ToList();
if (tasks.Count == 0)
{
if (requestedIds is { Count: > 0 })
return $"No matching sub-agents found for: {string.Join(", ", requestedIds)}";
return "No active sub-agents.";
}
if (!completedOnly)
await Task.WhenAll(tasks.Where(t => t.RunTask != null).Select(t => t.RunTask!)).WaitAsync(ct);
else
tasks = tasks.Where(t => t.CompletedAt != null).ToList();
if (tasks.Count == 0)
return "No requested sub-agents have completed yet.";
var sb = new StringBuilder();
sb.AppendLine($"Collected {tasks.Count} sub-agent result(s):");
foreach (var task in tasks.OrderBy(t => t.StartedAt))
{
var status = task.Success ? "OK" : "FAIL";
var duration = task.CompletedAt.HasValue
? $"{(task.CompletedAt.Value - task.StartedAt).TotalSeconds:F1}s"
: "running";
sb.AppendLine();
sb.AppendLine($"--- [{status}] {task.Id} ({duration}) ---");
sb.AppendLine(task.Result ?? "(no result)");
}
lock (_lock)
{
foreach (var task in tasks)
_activeTasks.Remove(task.Id);
}
return sb.ToString().TrimEnd();
}
public static void CleanupStale()
{
lock (_lock)
{
var stale = _activeTasks
.Where(kv => kv.Value.CompletedAt.HasValue &&
(DateTime.Now - kv.Value.CompletedAt.Value).TotalMinutes > 10)
.Select(kv => kv.Key)
.ToList();
foreach (var key in stale)
_activeTasks.Remove(key);
}
}
private static void NotifyStatus(SubAgentStatusEvent evt)
{
try { StatusChanged?.Invoke(evt); } catch { }
}
}
public class WaitAgentsTool : IAgentTool
{
public string Name => "wait_agents";
public string Description =>
"Wait for sub-agents and collect their results.\n" +
"You may wait for all sub-agents or only specific ids.\n" +
"Use completed_only=true to collect only already finished sub-agents without blocking.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["ids"] = new ToolProperty
{
Type = "array",
Description = "Optional list of sub-agent ids to collect. Omit to collect all.",
Items = new ToolProperty { Type = "string", Description = "Sub-agent id" }
},
["completed_only"] = new ToolProperty
{
Type = "boolean",
Description = "If true, collect only already completed sub-agents and do not wait."
},
},
Required = new()
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
List<string>? ids = null;
if (args.TryGetProperty("ids", out var idsEl) && idsEl.ValueKind == JsonValueKind.Array)
{
ids = idsEl.EnumerateArray()
.Where(x => x.ValueKind == JsonValueKind.String)
.Select(x => x.GetString() ?? "")
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
}
var completedOnly = args.TryGetProperty("completed_only", out var completedEl) &&
completedEl.ValueKind == JsonValueKind.True;
var result = await SubAgentTool.WaitAsync(ids, completedOnly, ct).ConfigureAwait(false);
return ToolResult.Ok(result);
}
}
public class SubAgentTask
{
public string Id { get; init; } = "";
public string Task { get; init; } = "";
public DateTime StartedAt { get; init; }
public DateTime? CompletedAt { get; set; }
public bool Success { get; set; }
public string? Result { get; set; }
public Task? RunTask { get; set; }
}
public enum SubAgentRunStatus
{
Started,
Completed,
Failed
}
public class SubAgentStatusEvent
{
public string Id { get; init; } = "";
public string Task { get; init; } = "";
public SubAgentRunStatus Status { get; init; }
public string Summary { get; init; } = "";
public string? Result { get; init; }
public DateTime Timestamp { get; init; } = DateTime.Now;
}

View File

@@ -0,0 +1,103 @@
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 작업 완료 후 후속 액션을 구조화하여 제안하는 도구.
/// UI가 클릭 가능한 칩으로 렌더링할 수 있도록 JSON 형태로 반환합니다.
/// </summary>
public class SuggestActionsTool : IAgentTool
{
public string Name => "suggest_actions";
public string Description =>
"Suggest 2-5 follow-up actions after completing a task. " +
"Returns structured JSON that the UI renders as clickable action chips. " +
"Each action has a label (display text), command (slash command or natural language prompt), " +
"optional icon (Segoe MDL2 Assets code), and priority (high/medium/low).";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["actions"] = new()
{
Type = "array",
Description = "List of action objects. Each object: {\"label\": \"표시 텍스트\", \"command\": \"/slash 또는 자연어\", \"icon\": \"\\uE8A5\" (optional), \"priority\": \"high|medium|low\"}",
Items = new() { Type = "object", Description = "Action object with label, command, icon, priority" },
},
["context"] = new()
{
Type = "string",
Description = "Current task context summary (optional)",
},
},
Required = ["actions"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
try
{
if (!args.TryGetProperty("actions", out var actionsEl) || actionsEl.ValueKind != JsonValueKind.Array)
return Task.FromResult(ToolResult.Fail("actions 배열이 필요합니다."));
var actions = new List<Dictionary<string, string>>();
foreach (var item in actionsEl.EnumerateArray())
{
var label = item.TryGetProperty("label", out var l) ? l.GetString() ?? "" : "";
var command = item.TryGetProperty("command", out var c) ? c.GetString() ?? "" : "";
var icon = item.TryGetProperty("icon", out var i) ? i.GetString() ?? "" : "";
var priority = item.TryGetProperty("priority", out var p) ? p.GetString() ?? "medium" : "medium";
if (string.IsNullOrWhiteSpace(label))
return Task.FromResult(ToolResult.Fail("각 action에는 label이 필요합니다."));
if (string.IsNullOrWhiteSpace(command))
return Task.FromResult(ToolResult.Fail("각 action에는 command가 필요합니다."));
// priority 유효성 검사
var validPriorities = new[] { "high", "medium", "low" };
if (!validPriorities.Contains(priority))
priority = "medium";
var action = new Dictionary<string, string>
{
["label"] = label,
["command"] = command,
["priority"] = priority,
};
if (!string.IsNullOrEmpty(icon))
action["icon"] = icon;
actions.Add(action);
}
if (actions.Count < 1 || actions.Count > 5)
return Task.FromResult(ToolResult.Fail("actions는 1~5개 사이여야 합니다."));
var contextSummary = args.TryGetProperty("context", out var ctx) ? ctx.GetString() ?? "" : "";
// 구조화된 JSON 응답 생성
var result = new Dictionary<string, object>
{
["type"] = "suggest_actions",
["actions"] = actions,
};
if (!string.IsNullOrEmpty(contextSummary))
result["context"] = contextSummary;
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
});
return Task.FromResult(ToolResult.Ok(json));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"액션 제안 오류: {ex.Message}"));
}
}
}

View File

@@ -0,0 +1,95 @@
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>
/// LLM 응답 텍스트에서 작업 계획(단계 목록)을 추출합니다.
/// Plan-and-Solve 논문의 경량 구현: 번호가 매겨진 단계를 파싱하여 진행률 추적에 사용합니다.
/// </summary>
public static class TaskDecomposer
{
// 번호 매긴 단계 패턴: "1. ...", "1) ...", "Step 1: ..."
private static readonly Regex StepPattern = new(
@"(?:^|\n)\s*(?:(?:Step\s*)?(\d+)[.):\-]\s*)(.+?)(?=\n|$)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>
/// LLM 텍스트 응답에서 계획 단계를 추출합니다.
/// </summary>
/// <returns>단계 목록. 2개 미만이면 빈 리스트 (계획이 아닌 것으로 판단).</returns>
public static List<string> ExtractSteps(string text)
{
if (string.IsNullOrWhiteSpace(text)) return [];
var matches = StepPattern.Matches(text);
if (matches.Count < 2) return [];
var steps = new List<string>();
int lastNum = 0;
foreach (Match m in matches)
{
if (int.TryParse(m.Groups[1].Value, out var num))
{
// 연속 번호인지 확인 (1,2,3... 또는 첫 번째)
if (num == lastNum + 1 || lastNum == 0)
{
var stepText = m.Groups[2].Value.Trim();
// 마크다운 기호 제거 (볼드, 이탤릭, 코드, 링크 등)
stepText = Regex.Replace(stepText, @"\*\*(.+?)\*\*", "$1"); // **볼드**
stepText = Regex.Replace(stepText, @"\*(.+?)\*", "$1"); // *이탤릭*
stepText = Regex.Replace(stepText, @"`(.+?)`", "$1"); // `코드`
stepText = Regex.Replace(stepText, @"\[(.+?)\]\(.+?\)", "$1"); // [링크](url)
stepText = stepText.TrimEnd(':', ' ');
// 너무 짧거나 긴 것은 제외
if (stepText.Length >= 3 && stepText.Length <= 300)
{
steps.Add(stepText);
lastNum = num;
}
}
}
}
// 최소 2단계, 최대 20단계
return steps.Count >= 2 ? steps.Take(20).ToList() : [];
}
/// <summary>
/// 도구 호출과 매칭하여 현재 어느 단계를 실행 중인지 추정합니다.
/// </summary>
public static int EstimateCurrentStep(List<string> steps, string toolName, string toolSummary, int lastStep)
{
if (steps.Count == 0) return 0;
// 다음 단계부터 검색 (역행하지 않음)
for (int i = lastStep; i < steps.Count; i++)
{
var step = steps[i].ToLowerInvariant();
var tool = toolName.ToLowerInvariant();
var summary = toolSummary.ToLowerInvariant();
// 단계 텍스트에 도구명이나 주요 키워드가 포함되면 매칭
if (step.Contains(tool) ||
ContainsKeywordOverlap(step, summary))
{
return i;
}
}
// 매칭 실패 → 마지막 단계 + 1로 전진 (최소 진행)
return Math.Min(lastStep + 1, steps.Count - 1);
}
private static bool ContainsKeywordOverlap(string stepText, string summary)
{
if (string.IsNullOrEmpty(summary)) return false;
// 의미 있는 키워드 추출 (3자 이상 단어)
var summaryWords = summary.Split(' ', '/', '\\', '.', ',', ':', ';', '(', ')')
.Where(w => w.Length >= 3)
.Take(5);
return summaryWords.Any(w => stepText.Contains(w));
}
}

View File

@@ -0,0 +1,209 @@
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AxCopilot.Services.Agent;
/// <summary>작업 폴더 내 TODO/태스크 추적 도구.</summary>
public class TaskTrackerTool : IAgentTool
{
public string Name => "task_tracker";
public string Description =>
"Track tasks/TODOs in the working folder. Actions: " +
"'scan' — scan source files for TODO/FIXME/HACK/BUG comments; " +
"'add' — add a task to .ax/tasks.json; " +
"'list' — list tasks from .ax/tasks.json; " +
"'done' — mark a task as completed.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "Action: scan, add, list, done",
Enum = ["scan", "add", "list", "done"],
},
["title"] = new()
{
Type = "string",
Description = "Task title (for 'add')",
},
["priority"] = new()
{
Type = "string",
Description = "Priority: high, medium, low (for 'add', default: medium)",
},
["id"] = new()
{
Type = "integer",
Description = "Task ID (for 'done')",
},
["extensions"] = new()
{
Type = "string",
Description = "File extensions to scan, comma-separated (default: .cs,.py,.js,.ts,.java,.cpp,.c)",
},
},
Required = ["action"],
};
private const string TaskFileName = ".ax/tasks.json";
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
try
{
return action switch
{
"scan" => Task.FromResult(ScanTodos(args, context)),
"add" => Task.FromResult(AddTask(args, context)),
"list" => Task.FromResult(ListTasks(context)),
"done" => Task.FromResult(MarkDone(args, context)),
_ => Task.FromResult(ToolResult.Fail($"Unknown action: {action}")),
};
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"태스크 추적 오류: {ex.Message}"));
}
}
private static ToolResult ScanTodos(JsonElement args, AgentContext context)
{
var extStr = args.TryGetProperty("extensions", out var e)
? e.GetString() ?? ".cs,.py,.js,.ts,.java,.cpp,.c"
: ".cs,.py,.js,.ts,.java,.cpp,.c";
var exts = new HashSet<string>(
extStr.Split(',').Select(s => s.Trim().StartsWith('.') ? s.Trim() : "." + s.Trim()),
StringComparer.OrdinalIgnoreCase);
var patterns = new[] { "TODO", "FIXME", "HACK", "BUG", "XXX" };
var results = new List<(string File, int Line, string Tag, string Text)>();
var workDir = context.WorkFolder;
if (!Directory.Exists(workDir))
return ToolResult.Fail($"작업 폴더 없음: {workDir}");
foreach (var file in Directory.EnumerateFiles(workDir, "*", SearchOption.AllDirectories))
{
if (!exts.Contains(Path.GetExtension(file))) continue;
if (file.Contains("bin") || file.Contains("obj") || file.Contains("node_modules")) continue;
var lines = TextFileCodec.SplitLines(TextFileCodec.ReadAllText(file).Text);
var lineNum = 0;
foreach (var line in lines)
{
lineNum++;
foreach (var pat in patterns)
{
var idx = line.IndexOf(pat, StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
{
var text = line[(idx + pat.Length)..].TrimStart(':', ' ');
if (text.Length > 100) text = text[..100] + "...";
var relPath = Path.GetRelativePath(workDir, file);
results.Add((relPath, lineNum, pat, text));
break;
}
}
if (results.Count >= 200) break;
}
if (results.Count >= 200) break;
}
if (results.Count == 0)
return ToolResult.Ok("TODO/FIXME 코멘트가 발견되지 않았습니다.");
var sb = new StringBuilder();
sb.AppendLine($"발견된 TODO 코멘트: {results.Count}개");
foreach (var (file, line, tag, text) in results)
sb.AppendLine($" [{tag}] {file}:{line} — {text}");
return ToolResult.Ok(sb.ToString());
}
private static ToolResult AddTask(JsonElement args, AgentContext context)
{
var title = args.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
if (string.IsNullOrEmpty(title))
return ToolResult.Fail("'title'이 필요합니다.");
var priority = args.TryGetProperty("priority", out var p) ? p.GetString() ?? "medium" : "medium";
var tasks = LoadTasks(context);
var maxId = tasks.Count > 0 ? tasks.Max(t2 => t2.Id) : 0;
tasks.Add(new TaskItem
{
Id = maxId + 1,
Title = title,
Priority = priority,
Created = DateTime.Now.ToString("yyyy-MM-dd HH:mm"),
Done = false,
});
SaveTasks(context, tasks);
return ToolResult.Ok($"태스크 추가: #{maxId + 1} — {title} (우선순위: {priority})");
}
private static ToolResult ListTasks(AgentContext context)
{
var tasks = LoadTasks(context);
if (tasks.Count == 0)
return ToolResult.Ok("등록된 태스크가 없습니다.");
var sb = new StringBuilder();
sb.AppendLine($"태스크 목록 ({tasks.Count}개):");
foreach (var task in tasks.OrderBy(t => t.Done).ThenByDescending(t => t.Priority == "high" ? 0 : t.Priority == "medium" ? 1 : 2))
{
var status = task.Done ? "✓" : "○";
sb.AppendLine($" {status} #{task.Id} [{task.Priority}] {task.Title} ({task.Created})");
}
return ToolResult.Ok(sb.ToString());
}
private static ToolResult MarkDone(JsonElement args, AgentContext context)
{
if (!args.TryGetProperty("id", out var idEl))
return ToolResult.Fail("'id'가 필요합니다.");
var id = idEl.GetInt32();
var tasks = LoadTasks(context);
var task = tasks.FirstOrDefault(t => t.Id == id);
if (task == null) return ToolResult.Fail($"태스크 #{id}를 찾을 수 없습니다.");
task.Done = true;
SaveTasks(context, tasks);
return ToolResult.Ok($"태스크 #{id} 완료: {task.Title}");
}
private static List<TaskItem> LoadTasks(AgentContext context)
{
var path = Path.Combine(context.WorkFolder, TaskFileName);
if (!File.Exists(path)) return new List<TaskItem>();
var json = TextFileCodec.ReadAllText(path).Text;
return JsonSerializer.Deserialize<List<TaskItem>>(json) ?? new List<TaskItem>();
}
private static void SaveTasks(AgentContext context, List<TaskItem> tasks)
{
var path = Path.Combine(context.WorkFolder, TaskFileName);
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
var json = JsonSerializer.Serialize(tasks, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(path, json, TextFileCodec.Utf8NoBom);
}
private class TaskItem
{
[JsonPropertyName("id")] public int Id { get; set; }
[JsonPropertyName("title")] public string Title { get; set; } = "";
[JsonPropertyName("priority")] public string Priority { get; set; } = "medium";
[JsonPropertyName("created")] public string Created { get; set; } = "";
[JsonPropertyName("done")] public bool Done { get; set; }
}
}

View File

@@ -0,0 +1,88 @@
namespace AxCopilot.Services.Agent;
/// <summary>
/// Task type specific runtime policy for agent loop guidance and quality gates.
/// </summary>
internal sealed class TaskTypePolicy
{
public required string TaskType { get; init; }
public required string GuidanceMessage { get; init; }
public required string FailurePatternFocus { get; init; }
public required string FollowUpTaskLine { get; init; }
public required string FailureInvestigationTaskLine { get; init; }
public required string FinalReportTaskLine { get; init; }
public bool IsReviewTask { get; init; }
public static TaskTypePolicy FromTaskType(string taskType)
{
return taskType switch
{
"bugfix" => new TaskTypePolicy
{
TaskType = "bugfix",
GuidanceMessage =
"[System:TaskType] This is a bug-fix task. Prioritize reproduction evidence, root cause linkage, smallest safe fix, and regression verification. " +
"Preferred tool order: file_read -> grep/glob -> build_run/test_loop -> file_edit -> build_run/test_loop.",
FailurePatternFocus = "재현 조건과 원인 연결을 먼저 확인하세요. Check reproduction conditions and root-cause linkage first.",
FollowUpTaskLine = "작업 유형: bugfix. Task type: bugfix. Verify the fix is directly linked to the symptom and confirm non-regression.\n",
FailureInvestigationTaskLine = "추가 점검: 재현 조건 기준으로 증상이 재현되지 않는지와 원인 연결이 타당한지 확인하세요. Extra check: confirm the symptom is no longer reproducible and root-cause linkage is valid.\n",
FinalReportTaskLine = "버그 수정은 원인, 수정 내용, 재현/회귀 검증 근거를 포함하세요. For bug fixes, include root cause, change summary, and reproduction/regression evidence.\n",
},
"feature" => new TaskTypePolicy
{
TaskType = "feature",
GuidanceMessage =
"[System:TaskType] This is a feature task. Prioritize affected interfaces/callers, data flow, validation paths, and test/documentation needs. " +
"Preferred tool order: folder_map -> file_read -> grep/glob -> file_edit -> build_run/test_loop.",
FailurePatternFocus = "Check new behavior flow and caller linkage first.",
FollowUpTaskLine = "작업 유형: feature. Task type: feature. Verify behavior flow, input/output path, caller impact, and test additions.\n",
FailureInvestigationTaskLine = "추가 점검: 새 기능 경로와 호출부 연결이 의도대로 동작하는지 확인하세요. Extra check: confirm feature path and caller linkage behave as intended.\n",
FinalReportTaskLine = "For features, include behavior flow, impacted files/callers, and verification evidence.\n",
},
"refactor" => new TaskTypePolicy
{
TaskType = "refactor",
GuidanceMessage =
"[System:TaskType] This is a refactor task. Prioritize behavior preservation, reference impact, diff review, and non-regression evidence. " +
"Preferred tool order: file_read -> grep/glob -> git_tool(diff) -> file_edit -> build_run/test_loop.",
FailurePatternFocus = "Check behavior preservation and impact scope first.",
FollowUpTaskLine = "작업 유형: refactor. Task type: refactor. Prioritize behavior-preservation evidence over cosmetic cleanup.\n",
FailureInvestigationTaskLine = "추가 점검: 동작 보존 관점에서 기존 호출 흐름이 동일하게 유지되는지 확인하세요. Extra check: validate existing call flow remains behavior-compatible.\n",
FinalReportTaskLine = "For refactors, include behavior-preservation evidence and impact scope.\n",
},
"review" => new TaskTypePolicy
{
TaskType = "review",
GuidanceMessage =
"[System:TaskType] This is a review task. Prioritize concrete defects, regressions, risky assumptions, and missing tests before summaries. " +
"Report findings with P0-P3 severity and file evidence, then separate Fixed vs Unfixed status. " +
"Preferred tool order: file_read -> grep/glob -> git_tool(diff) -> evidence-first findings.",
FailurePatternFocus = "Review focus: severity accuracy (P0-P3), file-grounded evidence, and unresolved-risk clarity.",
FollowUpTaskLine = "작업 유형: review-follow-up. Task type: review-follow-up. For each finding, state status as Fixed or Unfixed with verification evidence.\n",
FailureInvestigationTaskLine = "추가 점검: 리뷰에서 지적된 위험은 반드시 수정 근거나 미해결 사유/영향을 남기세요. Extra check: every risk must have either a concrete fix or an explicit unresolved rationale and impact.\n",
FinalReportTaskLine = "For review, list P0-P3 findings with file evidence and split into Fixed vs Unfixed with residual risk.\n",
IsReviewTask = true
},
"docs" => new TaskTypePolicy
{
TaskType = "docs",
GuidanceMessage =
"[System:TaskType] This is a document/content task. Prioritize source evidence, completeness, consistency, and self-review. " +
"Preferred tool order: folder_map -> document_read/file_read -> drafting tool -> self-review.",
FailurePatternFocus = "Check source evidence and document completeness first.",
FollowUpTaskLine = "",
FailureInvestigationTaskLine = "",
FinalReportTaskLine = "",
},
_ => new TaskTypePolicy
{
TaskType = "general",
GuidanceMessage = "[System:TaskType] Use a cautious analyze -> implement -> verify workflow and do not finish without concrete evidence.",
FailurePatternFocus = "Check recent failure patterns first.",
FollowUpTaskLine = "",
FailureInvestigationTaskLine = "",
FinalReportTaskLine = "",
}
};
}
}

View File

@@ -0,0 +1,203 @@
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Mustache 스타일 변수 치환 기반 템플릿 렌더링 도구.
/// {{variable}}, {{#list}}...{{/list}} 반복, {{^cond}}...{{/cond}} 조건부 렌더링을 지원합니다.
/// </summary>
public class TemplateRenderTool : IAgentTool
{
public string Name => "template_render";
public string Description =>
"Render a template file with variable substitution and loops. " +
"Supports Mustache-style syntax: {{variable}}, {{#list}}...{{/list}} loops, " +
"{{^variable}}...{{/variable}} inverted sections (if empty/false). " +
"Useful for generating repetitive documents like emails, reports, invoices from templates.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["template_path"] = new()
{
Type = "string",
Description = "Path to template file (.html, .md, .txt). Relative to work folder."
},
["template_text"] = new()
{
Type = "string",
Description = "Inline template text (used if template_path is not provided)."
},
["variables"] = new()
{
Type = "object",
Description = "Key-value pairs for substitution. Values can be strings, numbers, " +
"or arrays of objects for loops. Example: {\"name\": \"홍길동\", \"items\": [{\"product\": \"A\", \"qty\": 10}]}"
},
["output_path"] = new()
{
Type = "string",
Description = "Output file path. If not provided, returns rendered text."
},
},
Required = ["variables"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
// 템플릿 텍스트 로드
string template;
if (args.TryGetProperty("template_path", out var tpEl) && !string.IsNullOrEmpty(tpEl.GetString()))
{
var templatePath = FileReadTool.ResolvePath(tpEl.GetString()!, context.WorkFolder);
if (!context.IsPathAllowed(templatePath))
return ToolResult.Fail($"경로 접근 차단: {templatePath}");
if (!File.Exists(templatePath))
return ToolResult.Fail($"템플릿 파일 없음: {templatePath}");
template = (await TextFileCodec.ReadAllTextAsync(templatePath, ct)).Text;
}
else if (args.TryGetProperty("template_text", out var ttEl) && !string.IsNullOrEmpty(ttEl.GetString()))
{
template = ttEl.GetString()!;
}
else
{
return ToolResult.Fail("template_path 또는 template_text가 필요합니다.");
}
if (!args.TryGetProperty("variables", out var varsEl))
return ToolResult.Fail("variables가 필요합니다.");
try
{
// 렌더링
var rendered = Render(template, varsEl);
// 출력
if (args.TryGetProperty("output_path", out var opEl) && !string.IsNullOrEmpty(opEl.GetString()))
{
var outputPath = FileReadTool.ResolvePath(opEl.GetString()!, context.WorkFolder);
if (context.ActiveTab == "Cowork") outputPath = AgentContext.EnsureTimestampedPath(outputPath);
if (!context.IsPathAllowed(outputPath))
return ToolResult.Fail($"경로 접근 차단: {outputPath}");
if (!await context.CheckWritePermissionAsync(Name, outputPath))
return ToolResult.Fail($"쓰기 권한 거부: {outputPath}");
await TextFileCodec.WriteAllTextAsync(outputPath, rendered, TextFileCodec.Utf8NoBom, ct);
return ToolResult.Ok(
$"✅ 템플릿 렌더링 완료: {Path.GetFileName(outputPath)} ({rendered.Length:N0}자)",
outputPath);
}
// 파일로 저장하지 않으면 텍스트로 반환
if (rendered.Length > 4000)
return ToolResult.Ok($"✅ 렌더링 완료 ({rendered.Length:N0}자):\n{rendered[..3900]}...\n[이하 생략]");
return ToolResult.Ok($"✅ 렌더링 완료 ({rendered.Length:N0}자):\n{rendered}");
}
catch (Exception ex)
{
return ToolResult.Fail($"템플릿 렌더링 실패: {ex.Message}");
}
}
/// <summary>Mustache 스타일 템플릿을 렌더링합니다.</summary>
internal static string Render(string template, JsonElement variables)
{
var result = template;
// 1. 반복 섹션 {{#key}}...{{/key}}
result = Regex.Replace(result, @"\{\{#(\w+)\}\}(.*?)\{\{/\1\}\}", match =>
{
var key = match.Groups[1].Value;
var body = match.Groups[2].Value;
if (!variables.TryGetProperty(key, out var val)) return "";
if (val.ValueKind == JsonValueKind.Array)
{
var sb = new StringBuilder();
int index = 0;
foreach (var item in val.EnumerateArray())
{
var itemBody = body;
// {{.key}} 또는 {{key}} 치환
if (item.ValueKind == JsonValueKind.Object)
{
foreach (var prop in item.EnumerateObject())
{
itemBody = itemBody
.Replace($"{{{{{prop.Name}}}}}", prop.Value.ToString())
.Replace($"{{{{.{prop.Name}}}}}", prop.Value.ToString());
}
}
else
{
itemBody = itemBody.Replace("{{.}}", item.ToString());
}
// {{@index}} 치환
itemBody = itemBody.Replace("{{@index}}", (index + 1).ToString());
sb.Append(itemBody);
index++;
}
return sb.ToString();
}
// bool true → 섹션 표시
if (val.ValueKind == JsonValueKind.True)
return RenderSimpleVars(body, variables);
// 값이 있으면 표시
if (val.ValueKind != JsonValueKind.False &&
val.ValueKind != JsonValueKind.Null &&
val.ValueKind != JsonValueKind.Undefined)
return RenderSimpleVars(body, variables);
return "";
}, RegexOptions.Singleline);
// 2. 반전 섹션 {{^key}}...{{/key}} (값이 없거나 false일 때 표시)
result = Regex.Replace(result, @"\{\{\^(\w+)\}\}(.*?)\{\{/\1\}\}", match =>
{
var key = match.Groups[1].Value;
var body = match.Groups[2].Value;
if (!variables.TryGetProperty(key, out var val)) return body;
if (val.ValueKind == JsonValueKind.False ||
val.ValueKind == JsonValueKind.Null ||
(val.ValueKind == JsonValueKind.Array && val.GetArrayLength() == 0) ||
(val.ValueKind == JsonValueKind.String && string.IsNullOrEmpty(val.GetString())))
return body;
return "";
}, RegexOptions.Singleline);
// 3. 단순 변수 치환 {{key}}
result = RenderSimpleVars(result, variables);
return result;
}
private static string RenderSimpleVars(string text, JsonElement variables)
{
return Regex.Replace(text, @"\{\{(\w+)\}\}", match =>
{
var key = match.Groups[1].Value;
if (!variables.TryGetProperty(key, out var val)) return match.Value;
return val.ValueKind switch
{
JsonValueKind.String => val.GetString() ?? "",
JsonValueKind.Number => val.ToString(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => val.ToString()
};
});
}
}

View File

@@ -0,0 +1,734 @@
using Markdig;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 문서 디자인 템플릿 서비스.
/// 테마 무드(현대적, 전문가, 창의적 등)에 따라 CSS 스타일을 제공합니다.
/// HtmlSkill, DocxSkill 등에서 호출하여 문서 생성 시 적용합니다.
/// </summary>
public static class TemplateService
{
/// <summary>사용 가능한 테마 무드 목록.</summary>
public static readonly TemplateMood[] AvailableMoods =
[
new("modern", "현대적", "🔷", "깔끔한 라인, 넓은 여백, 미니멀한 색상 — 테크 기업 스타일"),
new("professional", "전문가", "📊", "신뢰감 있는 네이비 톤, 데이터 중심 레이아웃 — 비즈니스 보고서"),
new("creative", "아이디어", "🎨", "생동감 있는 그라데이션, 카드 레이아웃 — 브레인스토밍·기획서"),
new("minimal", "미니멀", "◻️", "극도로 절제된 흑백, 타이포그래피 중심 — 학술·논문 스타일"),
new("elegant", "우아한", "✨", "세리프 서체, 골드 포인트, 격식 있는 레이아웃 — 공식 문서"),
new("dark", "다크 모드", "🌙", "어두운 배경, 고대비 텍스트 — 개발자·야간 리딩"),
new("colorful", "컬러풀", "🌈", "밝고 활기찬 멀티 컬러, 둥근 모서리 — 프레젠테이션·요약"),
new("corporate", "기업 공식", "🏢", "보수적인 레이아웃, 로고 영역, 페이지 번호 — 사내 공식 보고서"),
new("magazine", "매거진", "📰", "멀티 컬럼, 큰 히어로 헤더, 인용 강조 — 뉴스레터·매거진"),
new("dashboard", "대시보드", "📈", "KPI 카드, 차트 영역, 그리드 레이아웃 — 데이터 대시보드"),
];
// ── 커스텀 무드 저장소 ──
private static readonly Dictionary<string, Models.CustomMoodEntry> _customMoods = new(StringComparer.OrdinalIgnoreCase);
/// <summary>커스텀 무드를 로드합니다 (앱 시작 시 SettingsService에서 호출).</summary>
public static void LoadCustomMoods(IEnumerable<Models.CustomMoodEntry> entries)
{
_customMoods.Clear();
foreach (var e in entries)
if (!string.IsNullOrWhiteSpace(e.Key))
_customMoods[e.Key] = e;
}
/// <summary>내장 + 커스텀 무드를 합친 전체 목록.</summary>
public static IReadOnlyList<TemplateMood> AllMoods
{
get
{
var list = new List<TemplateMood>(AvailableMoods);
foreach (var cm in _customMoods.Values)
list.Add(new TemplateMood(cm.Key, cm.Label, cm.Icon, cm.Description));
return list;
}
}
/// <summary>무드 키로 CSS 스타일을 반환합니다. 없으면 modern 기본값. 공통 CSS가 자동 첨부됩니다.</summary>
public static string GetCss(string moodKey)
{
// 커스텀 무드 우선 확인
if (_customMoods.TryGetValue(moodKey, out var custom))
return custom.Css + "\n" + CssShared;
var moodCss = moodKey switch
{
"modern" => CssModern,
"professional" => CssProfessional,
"creative" => CssCreative,
"minimal" => CssMinimal,
"elegant" => CssElegant,
"dark" => CssDark,
"colorful" => CssColorful,
"corporate" => CssCorporate,
"magazine" => CssMagazine,
"dashboard" => CssDashboard,
_ => CssModern,
};
return moodCss + "\n" + CssShared;
}
/// <summary>무드 키로 TemplateMood를 반환합니다.</summary>
public static TemplateMood? GetMood(string key)
{
var builtin = Array.Find(AvailableMoods, m => m.Key == key);
if (builtin != null) return builtin;
return _customMoods.TryGetValue(key, out var cm)
? new TemplateMood(cm.Key, cm.Label, cm.Icon, cm.Description)
: null;
}
/// <summary>LLM 시스템 프롬프트에 삽입할 무드 목록 설명.</summary>
public static string GetMoodListForPrompt()
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("Available document design moods (pass as 'mood' parameter to html_create):");
foreach (var m in AllMoods)
sb.AppendLine($" - \"{m.Key}\": {m.Label} — {m.Description}");
return sb.ToString();
}
/// <summary>Markdown 텍스트를 무드 CSS가 적용된 HTML 문서로 변환합니다.</summary>
public static string RenderMarkdownToHtml(string markdown, string moodKey = "modern")
{
var pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
var bodyHtml = Markdown.ToHtml(markdown, pipeline);
var css = GetCss(moodKey);
return $"""
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style>{css}</style>
</head>
<body>
<div class="container">
{bodyHtml}
</div>
</body>
</html>
""";
}
/// <summary>
/// HTML 본문에서 워크스페이스 하위 파일/폴더 경로를 파란색으로 강조합니다.
/// 코드 블록(&lt;code&gt;, &lt;pre&gt;) 내부의 경로 텍스트를 감지하여 파란색 스타일을 적용합니다.
/// </summary>
public static string HighlightFilePaths(string html, string? workFolder)
{
if (string.IsNullOrEmpty(workFolder) || string.IsNullOrEmpty(html))
return html;
// <code> 태그 내부의 텍스트 중 파일/폴더 경로 패턴을 감지하여 파란색으로 감싸기
// 패턴: 슬래시/백슬래시를 포함한 경로 또는 확장자가 있는 파일명
var pathPattern = new System.Text.RegularExpressions.Regex(
@"(?<![""'=/>])(\b[A-Za-z]:\\[^\s<>""]+|" + // 절대 경로: C:\folder\file.ext
@"\.{0,2}/[^\s<>""]+(?:\.[a-zA-Z]{1,10})?|" + // 상대 경로: ./src/file.cs, ../util.py
@"\b[\w\-]+(?:/[\w\-\.]+)+(?:\.[a-zA-Z]{1,10})?|" + // 폴더/파일: src/utils/helper.cs
@"\b[\w\-]+\.(?:cs|py|js|ts|tsx|jsx|json|xml|html|htm|css|md|txt|yml|yaml|toml|sh|bat|ps1|csproj|sln|docx|xlsx|pptx|pdf|csv)\b)", // 파일명.확장자
System.Text.RegularExpressions.RegexOptions.Compiled);
// <pre> 및 <code> 블록 외부의 텍스트에서만 치환
// 간단 접근: 모든 텍스트 노드에서 경로를 감지
return pathPattern.Replace(html, match =>
{
var path = match.Value;
// 이미 HTML 태그 내부이거나 href/src 속성값이면 건너뛰기
var prefix = html[..match.Index];
if (prefix.Length > 0)
{
var lastLt = prefix.LastIndexOf('<');
var lastGt = prefix.LastIndexOf('>');
if (lastLt > lastGt) return path; // 태그 속성 내부
}
return $"<span style=\"color:#3B82F6;font-weight:500;\">{path}</span>";
});
}
/// <summary>무드의 주요 색상을 반환합니다 (갤러리 미리보기용).</summary>
public static MoodColors GetMoodColors(string moodKey)
{
return moodKey switch
{
"modern" => new("#f5f5f7", "#ffffff", "#1d1d1f", "#6e6e73", "#0066cc", "#e5e5e7"),
"professional" => new("#f0f2f5", "#ffffff", "#1a365d", "#4a5568", "#2c5282", "#e2e8f0"),
"creative" => new("#faf5ff", "#ffffff", "#2d3748", "#718096", "#7c3aed", "#e9d5ff"),
"minimal" => new("#fafafa", "#ffffff", "#111111", "#555555", "#333333", "#e0e0e0"),
"elegant" => new("#fefdf8", "#fffef9", "#2c1810", "#6b5c4f", "#b8860b", "#e8e0d4"),
"dark" => new("#0d1117", "#161b22", "#e6edf3", "#8b949e", "#58a6ff", "#30363d"),
"colorful" => new("#f0f9ff", "#ffffff", "#1e293b", "#64748b", "#3b82f6", "#e0f2fe"),
"corporate" => new("#f3f4f6", "#ffffff", "#1f2937", "#6b7280", "#1e40af", "#e5e7eb"),
"magazine" => new("#f9fafb", "#ffffff", "#111827", "#6b7280", "#dc2626", "#f3f4f6"),
"dashboard" => new("#0f172a", "#1e293b", "#f1f5f9", "#94a3b8", "#3b82f6", "#334155"),
_ => new("#f5f5f7", "#ffffff", "#1d1d1f", "#6e6e73", "#0066cc", "#e5e5e7"),
};
}
/// <summary>무드 갤러리용 색상 정보.</summary>
public record MoodColors(string Background, string CardBg, string PrimaryText, string SecondaryText, string Accent, string Border);
// ════════════════════════════════════════════════════════════════════
// CSS 템플릿 정의
// ════════════════════════════════════════════════════════════════════
#region Modern
private const string CssModern = """
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
background: #f5f5f7; color: #1d1d1f; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 880px; margin: 0 auto; background: #fff;
border-radius: 16px; padding: 56px 52px;
box-shadow: 0 4px 24px rgba(0,0,0,0.06); }
h1 { font-size: 28px; font-weight: 700; letter-spacing: -0.5px; color: #1d1d1f; margin-bottom: 4px; }
h2 { font-size: 20px; font-weight: 600; margin: 36px 0 14px; color: #1d1d1f;
padding-bottom: 8px; border-bottom: 2px solid #e5e5ea; }
h3 { font-size: 16px; font-weight: 600; margin: 24px 0 10px; color: #0071e3; }
.meta { font-size: 12px; color: #86868b; margin-bottom: 28px; letter-spacing: 0.3px; }
p { margin: 10px 0; font-size: 14.5px; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 13.5px;
border-radius: 10px; overflow: hidden; }
th { background: #f5f5f7; text-align: left; padding: 12px 14px; font-weight: 600;
color: #1d1d1f; border-bottom: 2px solid #d2d2d7; }
td { padding: 10px 14px; border-bottom: 1px solid #f0f0f2; }
tr:hover td { background: #f9f9fb; }
ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; }
li { margin: 5px 0; }
code { background: #f5f5f7; padding: 2px 8px; border-radius: 6px; font-size: 13px;
font-family: 'SF Mono', Consolas, monospace; color: #e3116c; }
pre { background: #1d1d1f; color: #f5f5f7; padding: 20px; border-radius: 12px;
overflow-x: auto; font-size: 13px; margin: 16px 0; line-height: 1.6; }
pre code { background: transparent; color: inherit; padding: 0; }
blockquote { border-left: 3px solid #0071e3; padding: 12px 20px; margin: 16px 0;
background: #f0f7ff; color: #1d1d1f; border-radius: 0 8px 8px 0; font-size: 14px; }
.highlight { background: linear-gradient(120deg, #e0f0ff 0%, #f0e0ff 100%);
padding: 16px 20px; border-radius: 10px; margin: 16px 0; }
.badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px;
font-weight: 600; background: #0071e3; color: #fff; margin: 2px 4px 2px 0; }
""";
#endregion
#region Professional
private const string CssProfessional = """
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif;
background: #eef1f5; color: #1e293b; line-height: 1.7; padding: 40px 20px; }
.container { max-width: 900px; margin: 0 auto; background: #fff;
border-radius: 8px; padding: 48px;
box-shadow: 0 1px 8px rgba(0,0,0,0.08);
border-top: 4px solid #1e3a5f; }
h1 { font-size: 26px; font-weight: 700; color: #1e3a5f; margin-bottom: 4px; }
h2 { font-size: 18px; font-weight: 600; margin: 32px 0 12px; color: #1e3a5f;
border-bottom: 2px solid #c8d6e5; padding-bottom: 6px; }
h3 { font-size: 15px; font-weight: 600; margin: 22px 0 8px; color: #2c5282; }
.meta { font-size: 12px; color: #94a3b8; margin-bottom: 24px; border-bottom: 1px solid #e2e8f0;
padding-bottom: 12px; }
p { margin: 8px 0; font-size: 14px; }
table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13.5px;
border: 1px solid #e2e8f0; }
th { background: #1e3a5f; color: #fff; text-align: left; padding: 10px 14px;
font-weight: 600; font-size: 12.5px; text-transform: uppercase; letter-spacing: 0.5px; }
td { padding: 9px 14px; border-bottom: 1px solid #e2e8f0; }
tr:nth-child(even) td { background: #f8fafc; }
tr:hover td { background: #eef2ff; }
ul, ol { margin: 8px 0 8px 24px; }
li { margin: 4px 0; font-size: 14px; }
code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-size: 12.5px;
font-family: Consolas, monospace; color: #1e3a5f; }
pre { background: #0f172a; color: #e2e8f0; padding: 18px; border-radius: 6px;
overflow-x: auto; font-size: 12.5px; margin: 14px 0; }
pre code { background: transparent; color: inherit; padding: 0; }
blockquote { border-left: 4px solid #1e3a5f; padding: 10px 18px; margin: 14px 0;
background: #f0f4f8; color: #334155; }
.callout { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 6px;
padding: 14px 18px; margin: 14px 0; font-size: 13.5px; }
.callout strong { color: #1e40af; }
""";
#endregion
#region Creative
private const string CssCreative = """
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Poppins', 'Segoe UI', 'Malgun Gothic', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh; color: #2d3748; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 880px; margin: 0 auto; background: rgba(255,255,255,0.95);
backdrop-filter: blur(20px); border-radius: 20px; padding: 52px;
box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
h1 { font-size: 30px; font-weight: 700;
background: linear-gradient(135deg, #667eea, #e040fb);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
margin-bottom: 4px; }
h2 { font-size: 20px; font-weight: 600; margin: 36px 0 14px; color: #553c9a;
position: relative; padding-left: 16px; }
h2::before { content: ''; position: absolute; left: 0; top: 4px; width: 4px; height: 22px;
background: linear-gradient(180deg, #667eea, #e040fb); border-radius: 4px; }
h3 { font-size: 16px; font-weight: 600; margin: 22px 0 10px; color: #7c3aed; }
.meta { font-size: 12px; color: #a0aec0; margin-bottom: 28px; }
p { margin: 10px 0; font-size: 14.5px; }
table { width: 100%; border-collapse: separate; border-spacing: 0; margin: 18px 0;
font-size: 13.5px; border-radius: 12px; overflow: hidden;
box-shadow: 0 4px 12px rgba(102,126,234,0.1); }
th { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff;
text-align: left; padding: 12px 14px; font-weight: 600; }
td { padding: 10px 14px; border-bottom: 1px solid #f0e7fe; }
tr:hover td { background: #faf5ff; }
ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; }
li { margin: 5px 0; }
li::marker { color: #7c3aed; }
code { background: #f5f3ff; padding: 2px 8px; border-radius: 6px; font-size: 13px;
font-family: 'Fira Code', Consolas, monospace; color: #7c3aed; }
pre { background: #1a1a2e; color: #e0d4f5; padding: 20px; border-radius: 14px;
overflow-x: auto; font-size: 13px; margin: 16px 0;
border: 1px solid rgba(124,58,237,0.2); }
pre code { background: transparent; color: inherit; padding: 0; }
blockquote { border-left: 4px solid #7c3aed; padding: 14px 20px; margin: 16px 0;
background: linear-gradient(135deg, #f5f3ff, #faf5ff);
border-radius: 0 12px 12px 0; font-style: italic; }
.card { background: #fff; border: 1px solid #e9d8fd; border-radius: 14px;
padding: 20px; margin: 14px 0; box-shadow: 0 2px 8px rgba(124,58,237,0.08); }
.tag { display: inline-block; padding: 3px 12px; border-radius: 20px; font-size: 11px;
font-weight: 500; background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff; margin: 2px 4px 2px 0; }
""";
#endregion
#region Minimal
private const string CssMinimal = """
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Georgia', 'Batang', serif;
background: #fff; color: #222; line-height: 1.85; padding: 60px 24px; }
.container { max-width: 720px; margin: 0 auto; padding: 0; }
h1 { font-size: 32px; font-weight: 400; color: #000; margin-bottom: 4px;
letter-spacing: -0.5px; }
h2 { font-size: 20px; font-weight: 400; margin: 40px 0 14px; color: #000;
border-bottom: 1px solid #ddd; padding-bottom: 8px; }
h3 { font-size: 16px; font-weight: 600; margin: 28px 0 10px; color: #333; }
.meta { font-size: 12px; color: #999; margin-bottom: 36px; font-style: italic; }
p { margin: 12px 0; font-size: 15px; text-align: justify; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 14px; }
th { text-align: left; padding: 8px 0; font-weight: 600; border-bottom: 2px solid #000;
font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: #555; }
td { padding: 8px 0; border-bottom: 1px solid #eee; }
tr:hover td { background: #fafafa; }
ul, ol { margin: 12px 0 12px 20px; font-size: 15px; }
li { margin: 6px 0; }
code { background: #f7f7f7; padding: 2px 6px; border-radius: 2px; font-size: 13px;
font-family: 'Courier New', monospace; }
pre { background: #f7f7f7; color: #333; padding: 18px; margin: 16px 0;
overflow-x: auto; font-size: 13px; border: 1px solid #e5e5e5; }
pre code { background: transparent; padding: 0; }
blockquote { border-left: 3px solid #000; padding: 8px 20px; margin: 16px 0;
color: #555; font-style: italic; }
hr { border: none; border-top: 1px solid #ddd; margin: 32px 0; }
""";
#endregion
#region Elegant
private const string CssElegant = """
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Source+Sans+3:wght@300;400;600&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Source Sans 3', 'Malgun Gothic', sans-serif;
background: #faf8f5; color: #3d3929; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 860px; margin: 0 auto; background: #fff;
border-radius: 4px; padding: 56px 52px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
border: 1px solid #e8e4dd; }
h1 { font-family: 'Playfair Display', Georgia, serif; font-size: 30px;
font-weight: 700; color: #2c2416; margin-bottom: 6px; letter-spacing: -0.3px; }
h2 { font-family: 'Playfair Display', Georgia, serif; font-size: 20px;
font-weight: 600; margin: 36px 0 14px; color: #2c2416;
border-bottom: 1px solid #d4c9b8; padding-bottom: 8px; }
h3 { font-size: 15px; font-weight: 600; margin: 24px 0 10px; color: #8b7a5e; }
.meta { font-size: 12px; color: #b0a48e; margin-bottom: 28px; letter-spacing: 0.5px;
text-transform: uppercase; }
p { margin: 10px 0; font-size: 14.5px; }
table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 13.5px; }
th { background: #f8f5f0; text-align: left; padding: 10px 14px; font-weight: 600;
color: #5a4d38; border-bottom: 2px solid #d4c9b8; font-size: 12.5px;
letter-spacing: 0.5px; }
td { padding: 9px 14px; border-bottom: 1px solid #f0ece5; }
tr:hover td { background: #fdfcfa; }
ul, ol { margin: 10px 0 10px 26px; font-size: 14.5px; }
li { margin: 5px 0; }
code { background: #f8f5f0; padding: 2px 7px; border-radius: 3px; font-size: 12.5px;
font-family: 'Courier New', monospace; color: #8b6914; }
pre { background: #2c2416; color: #e8e0d0; padding: 18px; border-radius: 4px;
overflow-x: auto; font-size: 12.5px; margin: 16px 0; }
pre code { background: transparent; color: inherit; padding: 0; }
blockquote { border-left: 3px solid #c9a96e; padding: 12px 20px; margin: 16px 0;
background: #fdf9f0; color: #5a4d38; font-style: italic;
font-family: 'Playfair Display', Georgia, serif; }
.ornament { text-align: center; color: #c9a96e; font-size: 18px; margin: 24px 0; letter-spacing: 8px; }
""";
#endregion
#region Dark
private const string CssDark = """
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Inter:wght@300;400;500;600;700&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
background: #0d1117; color: #e6edf3; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 880px; margin: 0 auto; background: #161b22;
border-radius: 12px; padding: 52px;
border: 1px solid #30363d;
box-shadow: 0 8px 32px rgba(0,0,0,0.3); }
h1 { font-size: 28px; font-weight: 700; color: #f0f6fc; margin-bottom: 4px; }
h2 { font-size: 20px; font-weight: 600; margin: 36px 0 14px; color: #f0f6fc;
border-bottom: 1px solid #30363d; padding-bottom: 8px; }
h3 { font-size: 16px; font-weight: 600; margin: 24px 0 10px; color: #58a6ff; }
.meta { font-size: 12px; color: #8b949e; margin-bottom: 28px; }
p { margin: 10px 0; font-size: 14.5px; color: #c9d1d9; }
table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 13.5px;
border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
th { background: #21262d; text-align: left; padding: 10px 14px; font-weight: 600;
color: #f0f6fc; border-bottom: 1px solid #30363d; }
td { padding: 9px 14px; border-bottom: 1px solid #21262d; color: #c9d1d9; }
tr:hover td { background: #1c2128; }
ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; color: #c9d1d9; }
li { margin: 5px 0; }
code { background: #1c2128; padding: 2px 8px; border-radius: 6px; font-size: 13px;
font-family: 'JetBrains Mono', Consolas, monospace; color: #79c0ff; }
pre { background: #0d1117; color: #c9d1d9; padding: 20px; border-radius: 8px;
overflow-x: auto; font-size: 13px; margin: 16px 0;
border: 1px solid #30363d; }
pre code { background: transparent; color: inherit; padding: 0; }
blockquote { border-left: 3px solid #58a6ff; padding: 12px 20px; margin: 16px 0;
background: #161b22; color: #8b949e;
border-radius: 0 8px 8px 0; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.label { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px;
font-weight: 500; border: 1px solid #30363d; color: #8b949e; margin: 2px 4px 2px 0; }
""";
#endregion
#region Colorful
private const string CssColorful = """
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;600;700;800&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Nunito', 'Segoe UI', 'Malgun Gothic', sans-serif;
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 50%, #ffecd2 100%);
min-height: 100vh; color: #2d3436; line-height: 1.75; padding: 48px 24px; }
.container { max-width: 880px; margin: 0 auto; background: #fff;
border-radius: 20px; padding: 52px;
box-shadow: 0 12px 40px rgba(0,0,0,0.08); }
h1 { font-size: 30px; font-weight: 800; color: #e17055; margin-bottom: 4px; }
h2 { font-size: 20px; font-weight: 700; margin: 34px 0 14px; color: #6c5ce7;
padding: 6px 14px; background: #f8f0ff; border-radius: 8px; display: inline-block; }
h3 { font-size: 16px; font-weight: 700; margin: 22px 0 10px; color: #00b894; }
.meta { font-size: 12px; color: #b2bec3; margin-bottom: 28px; }
p { margin: 10px 0; font-size: 14.5px; }
table { width: 100%; border-collapse: separate; border-spacing: 0; margin: 18px 0;
font-size: 13.5px; border-radius: 14px; overflow: hidden;
box-shadow: 0 2px 8px rgba(108,92,231,0.1); }
th { background: linear-gradient(135deg, #a29bfe, #6c5ce7); color: #fff;
text-align: left; padding: 12px 14px; font-weight: 700; }
td { padding: 10px 14px; border-bottom: 1px solid #f0f0f0; }
tr:hover td { background: #faf0ff; }
ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; }
li { margin: 5px 0; }
li::marker { color: #e17055; font-weight: 700; }
code { background: #fff3e0; padding: 2px 8px; border-radius: 6px; font-size: 13px;
font-family: Consolas, monospace; color: #e17055; }
pre { background: #2d3436; color: #dfe6e9; padding: 20px; border-radius: 14px;
overflow-x: auto; font-size: 13px; margin: 16px 0; }
pre code { background: transparent; color: inherit; padding: 0; }
blockquote { border-left: 4px solid #fdcb6e; padding: 14px 20px; margin: 16px 0;
background: #fffbf0; border-radius: 0 12px 12px 0; color: #636e72; }
.chip { display: inline-block; padding: 4px 14px; border-radius: 20px; font-size: 12px;
font-weight: 700; color: #fff; margin: 3px 4px 3px 0; }
.chip-red { background: #e17055; } .chip-blue { background: #74b9ff; }
.chip-green { background: #00b894; } .chip-purple { background: #6c5ce7; }
.chip-yellow { background: #fdcb6e; color: #2d3436; }
""";
#endregion
#region Corporate
private const string CssCorporate = """
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif;
background: #f2f2f2; color: #333; line-height: 1.65; padding: 32px 20px; }
.container { max-width: 900px; margin: 0 auto; background: #fff; padding: 0;
box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
.header-bar { background: #003366; color: #fff; padding: 28px 40px 20px;
border-bottom: 3px solid #ff6600; }
.header-bar h1 { font-size: 22px; font-weight: 700; color: #fff; margin-bottom: 2px; }
.header-bar .meta { color: rgba(255,255,255,0.7); margin-bottom: 0; font-size: 12px; }
.body-content { padding: 36px 40px 40px; }
h1 { font-size: 22px; font-weight: 700; color: #003366; margin-bottom: 4px; }
h2 { font-size: 17px; font-weight: 600; margin: 28px 0 10px; color: #003366;
border-left: 4px solid #ff6600; padding-left: 12px; }
h3 { font-size: 14.5px; font-weight: 600; margin: 20px 0 8px; color: #004488; }
.meta { font-size: 11.5px; color: #999; margin-bottom: 20px; }
p { margin: 8px 0; font-size: 13.5px; }
table { width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 12.5px;
border: 1px solid #ddd; }
th { background: #003366; color: #fff; text-align: left; padding: 8px 12px;
font-weight: 600; font-size: 11.5px; }
td { padding: 7px 12px; border: 1px solid #e0e0e0; }
tr:nth-child(even) td { background: #f9f9f9; }
ul, ol { margin: 8px 0 8px 24px; font-size: 13.5px; }
li { margin: 3px 0; }
code { background: #f4f4f4; padding: 1px 5px; border-radius: 3px; font-size: 12px;
font-family: Consolas, monospace; }
pre { background: #f4f4f4; color: #333; padding: 14px; border-radius: 4px;
overflow-x: auto; font-size: 12px; margin: 12px 0; border: 1px solid #ddd; }
pre code { background: transparent; padding: 0; }
blockquote { border-left: 4px solid #ff6600; padding: 10px 16px; margin: 12px 0;
background: #fff8f0; color: #555; }
.footer { text-align: center; font-size: 10.5px; color: #aaa; margin-top: 32px;
padding-top: 12px; border-top: 1px solid #eee; }
.stamp { display: inline-block; border: 2px solid #003366; color: #003366; padding: 4px 16px;
border-radius: 4px; font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 1px; }
""";
#endregion
#region Magazine
private const string CssMagazine = """
@import url('https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&family=Open+Sans:wght@300;400;600;700&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Open Sans', 'Malgun Gothic', sans-serif;
background: #f0ece3; color: #2c2c2c; line-height: 1.7; padding: 48px 24px; }
.container { max-width: 900px; margin: 0 auto; background: #fff;
border-radius: 2px; padding: 0; overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
.hero { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
padding: 48px 44px 36px; color: #fff; }
.hero h1 { font-family: 'Merriweather', Georgia, serif; font-size: 32px; font-weight: 900;
line-height: 1.3; margin-bottom: 8px; }
.hero .meta { color: rgba(255,255,255,0.6); margin-bottom: 0; font-size: 13px; }
.content { padding: 40px 44px 44px; }
h1 { font-family: 'Merriweather', Georgia, serif; font-size: 28px; font-weight: 900;
color: #1a1a2e; margin-bottom: 4px; }
h2 { font-family: 'Merriweather', Georgia, serif; font-size: 20px; font-weight: 700;
margin: 36px 0 14px; color: #1a1a2e; }
h3 { font-size: 15px; font-weight: 700; margin: 24px 0 10px; color: #e94560;
text-transform: uppercase; letter-spacing: 1px; font-size: 12px; }
.meta { font-size: 12px; color: #999; margin-bottom: 24px; }
p { margin: 10px 0; font-size: 15px; }
p:first-of-type::first-letter { font-family: 'Merriweather', Georgia, serif;
font-size: 48px; float: left; line-height: 1; padding-right: 8px; color: #e94560;
font-weight: 900; }
table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 13.5px; }
th { background: #1a1a2e; color: #fff; text-align: left; padding: 10px 14px;
font-weight: 600; }
td { padding: 9px 14px; border-bottom: 1px solid #eee; }
tr:hover td { background: #fafafa; }
ul, ol { margin: 10px 0 10px 28px; font-size: 14.5px; }
li { margin: 5px 0; }
code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; font-size: 12.5px;
font-family: 'Courier New', monospace; }
pre { background: #1a1a2e; color: #e0e0e0; padding: 18px; border-radius: 4px;
overflow-x: auto; font-size: 12.5px; margin: 16px 0; }
pre code { background: transparent; color: inherit; padding: 0; }
blockquote { font-family: 'Merriweather', Georgia, serif; font-size: 18px;
font-style: italic; color: #555; border: none; padding: 20px 0; margin: 24px 0;
text-align: center; position: relative; }
blockquote::before { content: '\201C'; font-size: 60px; color: #e94560;
position: absolute; top: -10px; left: 50%; transform: translateX(-50%);
opacity: 0.3; }
.pullquote { font-size: 20px; font-family: 'Merriweather', Georgia, serif;
font-weight: 700; color: #e94560; border-top: 3px solid #e94560;
border-bottom: 3px solid #e94560; padding: 16px 0; margin: 24px 0;
text-align: center; }
""";
#endregion
#region Dashboard
private const string CssDashboard = """
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
background: #f0f2f5; color: #1a1a2e; line-height: 1.6; padding: 32px 24px; }
.container { max-width: 1000px; margin: 0 auto; padding: 0; background: transparent; }
h1 { font-size: 26px; font-weight: 700; color: #1a1a2e; margin-bottom: 4px; }
h2 { font-size: 17px; font-weight: 600; margin: 28px 0 14px; color: #1a1a2e; }
h3 { font-size: 14px; font-weight: 600; margin: 18px 0 8px; color: #6c7893; }
.meta { font-size: 12px; color: #8c95a6; margin-bottom: 24px; }
p { margin: 8px 0; font-size: 13.5px; color: #4a5568; }
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px; margin: 20px 0; }
.kpi-card { background: #fff; border-radius: 12px; padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.kpi-card .kpi-label { font-size: 12px; color: #8c95a6; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.5px; }
.kpi-card .kpi-value { font-size: 28px; font-weight: 700; color: #1a1a2e; margin: 4px 0; }
.kpi-card .kpi-change { font-size: 12px; font-weight: 600; }
.kpi-up { color: #10b981; } .kpi-down { color: #ef4444; }
.chart-area { background: #fff; border-radius: 12px; padding: 24px; margin: 16px 0;
box-shadow: 0 1px 4px rgba(0,0,0,0.06); min-height: 200px; }
table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13px;
background: #fff; border-radius: 10px; overflow: hidden;
box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
th { background: #f7f8fa; text-align: left; padding: 10px 14px; font-weight: 600;
color: #6c7893; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.5px;
border-bottom: 1px solid #edf0f4; }
td { padding: 10px 14px; border-bottom: 1px solid #f3f4f6; }
tr:hover td { background: #f9fafb; }
ul, ol { margin: 8px 0 8px 24px; font-size: 13.5px; }
li { margin: 4px 0; }
code { background: #f1f3f5; padding: 2px 7px; border-radius: 5px; font-size: 12px;
font-family: 'JetBrains Mono', Consolas, monospace; }
pre { background: #1a1a2e; color: #c9d1d9; padding: 18px; border-radius: 10px;
overflow-x: auto; font-size: 12px; margin: 14px 0; }
pre code { background: transparent; color: inherit; padding: 0; }
blockquote { border-left: 3px solid #4b5efc; padding: 10px 16px; margin: 14px 0;
background: #f0f0ff; border-radius: 0 8px 8px 0; font-size: 13px; }
.status-badge { display: inline-block; padding: 2px 10px; border-radius: 12px;
font-size: 11px; font-weight: 600; }
.status-ok { background: #d1fae5; color: #065f46; }
.status-warn { background: #fef3c7; color: #92400e; }
.status-err { background: #fee2e2; color: #991b1b; }
""";
#endregion
// ════════════════════════════════════════════════════════════════════
// 공통 CSS 컴포넌트 (모든 무드에 자동 첨부)
// ════════════════════════════════════════════════════════════════════
#region Shared
private const string CssShared = """
/* ── 목차 (TOC) ── */
nav.toc { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 10px;
padding: 20px 28px; margin: 24px 0 32px; }
nav.toc h2 { font-size: 15px; font-weight: 700; margin: 0 0 12px; padding: 0; border: none;
color: inherit; display: block; background: none; }
nav.toc ul { list-style: none; margin: 0; padding: 0; }
nav.toc li { margin: 4px 0; }
nav.toc li.toc-h3 { padding-left: 18px; }
nav.toc a { text-decoration: none; color: #4b5efc; font-size: 13.5px; }
nav.toc a:hover { text-decoration: underline; }
/* ── 커버 페이지 ── */
.cover-page { text-align: center; padding: 80px 40px 60px; margin: -56px -52px 40px;
border-radius: 16px 16px 0 0; position: relative; overflow: hidden;
background: linear-gradient(135deg, #4b5efc 0%, #7c3aed 100%); color: #fff; }
.cover-page h1 { font-size: 36px; font-weight: 800; margin-bottom: 12px; color: #fff;
-webkit-text-fill-color: #fff; }
.cover-page .cover-subtitle { font-size: 18px; opacity: 0.9; margin-bottom: 24px; }
.cover-page .cover-meta { font-size: 13px; opacity: 0.7; }
.cover-page .cover-divider { width: 60px; height: 3px; background: rgba(255,255,255,0.5);
margin: 20px auto; border-radius: 2px; }
/* ── 콜아웃 (callout) ── */
.callout { border-radius: 8px; padding: 16px 20px; margin: 16px 0; font-size: 14px;
border-left: 4px solid; display: flex; gap: 10px; align-items: flex-start; }
.callout::before { font-size: 16px; flex-shrink: 0; margin-top: 1px; }
.callout-info { background: #eff6ff; border-color: #3b82f6; color: #1e40af; }
.callout-info::before { content: ''; }
.callout-warning { background: #fffbeb; border-color: #f59e0b; color: #92400e; }
.callout-warning::before { content: ''; }
.callout-tip { background: #f0fdf4; border-color: #22c55e; color: #166534; }
.callout-tip::before { content: '💡'; }
.callout-danger { background: #fef2f2; border-color: #ef4444; color: #991b1b; }
.callout-danger::before { content: '🚨'; }
.callout-note { background: #f5f3ff; border-color: #8b5cf6; color: #5b21b6; }
.callout-note::before { content: '📝'; }
/* ── 배지 (badge) — 공통 ── */
.badge, .tag, .chip { display: inline-block; padding: 3px 10px; border-radius: 20px;
font-size: 11px; font-weight: 600; margin: 2px 4px 2px 0; }
.badge-blue { background: #dbeafe; color: #1e40af; }
.badge-green { background: #d1fae5; color: #065f46; }
.badge-red { background: #fee2e2; color: #991b1b; }
.badge-yellow { background: #fef3c7; color: #92400e; }
.badge-purple { background: #ede9fe; color: #5b21b6; }
.badge-gray { background: #f3f4f6; color: #374151; }
.badge-orange { background: #ffedd5; color: #9a3412; }
/* ── 하이라이트 박스 ── */
.highlight-box { background: linear-gradient(120deg, #e0f0ff 0%, #f0e0ff 100%);
padding: 16px 20px; border-radius: 10px; margin: 16px 0; }
/* ── CSS 차트 (bar/horizontal) ── */
.chart-bar { margin: 20px 0; }
.chart-bar .bar-item { display: flex; align-items: center; margin: 6px 0; gap: 10px; }
.chart-bar .bar-label { min-width: 100px; font-size: 13px; text-align: right; flex-shrink: 0; }
.chart-bar .bar-track { flex: 1; background: #e5e7eb; border-radius: 6px; height: 22px;
overflow: hidden; }
.chart-bar .bar-fill { height: 100%; border-radius: 6px; display: flex; align-items: center;
padding: 0 8px; font-size: 11px; font-weight: 600; color: #fff;
transition: width 0.3s ease; min-width: fit-content; }
.bar-fill.blue { background: #3b82f6; } .bar-fill.green { background: #22c55e; }
.bar-fill.red { background: #ef4444; } .bar-fill.yellow { background: #f59e0b; }
.bar-fill.purple { background: #8b5cf6; } .bar-fill.orange { background: #f97316; }
/* ── CSS 도넛 차트 ── */
.chart-donut { width: 160px; height: 160px; border-radius: 50%; margin: 20px auto;
background: conic-gradient(var(--seg1-color, #3b82f6) 0% var(--seg1, 0%),
var(--seg2-color, #22c55e) var(--seg1, 0%) var(--seg2, 0%),
var(--seg3-color, #f59e0b) var(--seg2, 0%) var(--seg3, 0%),
var(--seg4-color, #ef4444) var(--seg3, 0%) var(--seg4, 0%),
#e5e7eb var(--seg4, 0%) 100%);
display: flex; align-items: center; justify-content: center; position: relative; }
.chart-donut::after { content: ''; width: 100px; height: 100px; background: #fff;
border-radius: 50%; position: absolute; }
.chart-donut .donut-label { position: absolute; z-index: 1; font-size: 18px; font-weight: 700; }
/* ── 진행률 바 ── */
.progress { background: #e5e7eb; border-radius: 8px; height: 10px; margin: 8px 0;
overflow: hidden; }
.progress-fill { height: 100%; border-radius: 8px; background: #3b82f6; }
/* ── 타임라인 ── */
.timeline { position: relative; padding-left: 28px; margin: 20px 0; }
.timeline::before { content: ''; position: absolute; left: 8px; top: 0; bottom: 0;
width: 2px; background: #e5e7eb; }
.timeline-item { position: relative; margin: 16px 0; }
.timeline-item::before { content: ''; position: absolute; left: -24px; top: 5px;
width: 12px; height: 12px; border-radius: 50%; background: #4b5efc;
border: 2px solid #fff; box-shadow: 0 0 0 2px #4b5efc; }
.timeline-item .timeline-date { font-size: 12px; color: #6b7280; font-weight: 600; }
.timeline-item .timeline-content { font-size: 14px; margin-top: 4px; }
/* ── 섹션 자동 번호 ── */
body { counter-reset: section; }
h2.numbered { counter-increment: section; counter-reset: subsection; }
h2.numbered::before { content: counter(section) '. '; }
h3.numbered { counter-increment: subsection; }
h3.numbered::before { content: counter(section) '-' counter(subsection) '. '; }
/* ── 그리드 레이아웃 ── */
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin: 16px 0; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin: 16px 0; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin: 16px 0; }
/* ── 카드 공통 ── */
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;
padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.card-header { font-size: 15px; font-weight: 700; margin-bottom: 8px; }
/* ── 구분선 ── */
.divider { border: none; border-top: 1px solid #e5e7eb; margin: 32px 0; }
.divider-thick { border: none; border-top: 3px solid #e5e7eb; margin: 40px 0; }
/* ── 인쇄/PDF 최적화 ── */
@media print {
body { background: #fff !important; padding: 0 !important; }
.container { box-shadow: none !important; border: none !important;
max-width: none !important; padding: 20px !important; }
.cover-page { break-after: page; }
h2, h3 { break-after: avoid; }
table, figure, .chart-bar, .callout { break-inside: avoid; }
nav.toc { break-after: page; }
a { color: inherit !important; text-decoration: none !important; }
a[href]::after { content: ' (' attr(href) ')'; font-size: 10px; color: #999; }
.no-print { display: none !important; }
}
""";
#endregion
}
/// <summary>테마 무드 정의.</summary>
public record TemplateMood(string Key, string Label, string Icon, string Description);

View File

@@ -0,0 +1,316 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 자동 테스트 생성 + 실행 + 결과 분석 도구.
/// 코드 변경 → 관련 테스트 자동 생성 → 실행 → 결과 기반 피드백 루프.
/// </summary>
public class TestLoopTool : IAgentTool
{
public string Name => "test_loop";
public string Description =>
"코드 변경에 대한 테스트를 자동으로 생성하고 실행합니다.\n" +
"- action=\"generate\": 변경된 파일에 대한 테스트 코드 생성 제안\n" +
"- action=\"run\": 프로젝트의 테스트를 실행하고 결과 반환\n" +
"- action=\"analyze\": 테스트 결과를 분석하여 수정 방향 제시\n" +
"- action=\"auto_fix\": 테스트 실행 → 실패 파싱 → 구조화된 수정 지침 반환 (반복 수정용)\n" +
"테스트 프레임워크를 자동 감지합니다 (xUnit, NUnit, MSTest, pytest, Jest 등).";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new ToolProperty
{
Type = "string",
Description = "수행할 작업: generate | run | analyze | auto_fix",
Enum = new() { "generate", "run", "analyze", "auto_fix" }
},
["file_path"] = new ToolProperty
{
Type = "string",
Description = "대상 소스 파일 경로 (generate 시 필요)"
},
["test_output"] = new ToolProperty
{
Type = "string",
Description = "분석할 테스트 출력 (analyze 시 필요)"
},
},
Required = new() { "action" }
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
return action switch
{
"generate" => GenerateTestSuggestion(args, context),
"run" => await RunTestsAsync(context, ct),
"analyze" => AnalyzeTestOutput(args),
"auto_fix" => await AutoFixAsync(context, ct),
_ => ToolResult.Fail("action은 generate, run, analyze, auto_fix 중 하나여야 합니다.")
};
}
private static ToolResult GenerateTestSuggestion(JsonElement args, AgentContext context)
{
var filePath = args.TryGetProperty("file_path", out var f) ? f.GetString() ?? "" : "";
if (string.IsNullOrEmpty(filePath))
return ToolResult.Fail("file_path가 필요합니다.");
if (!Path.IsPathRooted(filePath) && !string.IsNullOrEmpty(context.WorkFolder))
filePath = Path.Combine(context.WorkFolder, filePath);
if (!File.Exists(filePath))
return ToolResult.Fail($"파일 없음: {filePath}");
var ext = Path.GetExtension(filePath).ToLowerInvariant();
var (framework, testExt, convention) = ext switch
{
".cs" => ("xUnit/NUnit/MSTest", ".cs", "ClassNameTests.cs"),
".py" => ("pytest", ".py", "test_module.py"),
".ts" or ".tsx" => ("Jest/Vitest", ".test.ts", "Component.test.ts"),
".js" or ".jsx" => ("Jest", ".test.js", "module.test.js"),
".java" => ("JUnit", ".java", "ClassTest.java"),
".go" => ("go test", "_test.go", "module_test.go"),
_ => ("unknown", ext, "test" + ext),
};
var content = TextFileCodec.ReadAllText(filePath).Text;
var lineCount = content.Split('\n').Length;
return ToolResult.Ok(
$"테스트 생성 제안:\n" +
$" 대상 파일: {Path.GetFileName(filePath)} ({lineCount}줄)\n" +
$" 감지된 프레임워크: {framework}\n" +
$" 테스트 파일 명명: {convention}\n" +
$" 테스트 파일 확장자: {testExt}\n\n" +
$"file_write 도구로 테스트 파일을 생성한 후, test_loop action=\"run\"으로 실행하세요.");
}
private static async Task<ToolResult> RunTestsAsync(AgentContext context, CancellationToken ct)
{
if (string.IsNullOrEmpty(context.WorkFolder))
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
// 프로젝트 타입 자동 감지 → 테스트 명령 결정
var (cmd, cmdArgs) = DetectTestCommand(context.WorkFolder);
if (cmd == null)
return ToolResult.Fail("테스트 프레임워크를 감지할 수 없습니다. 지원: .NET (dotnet test), Python (pytest), Node.js (npm test)");
try
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = cmd,
Arguments = cmdArgs,
WorkingDirectory = context.WorkFolder,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
using var proc = System.Diagnostics.Process.Start(psi);
if (proc == null) return ToolResult.Fail("테스트 프로세스 시작 실패");
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(120));
var stdout = await proc.StandardOutput.ReadToEndAsync(cts.Token);
var stderr = await proc.StandardError.ReadToEndAsync(cts.Token);
await proc.WaitForExitAsync(cts.Token);
var sb = new StringBuilder();
sb.AppendLine($"테스트 실행 결과 (exit code: {proc.ExitCode}):");
sb.AppendLine($"명령: {cmd} {cmdArgs}");
sb.AppendLine();
if (!string.IsNullOrWhiteSpace(stdout)) sb.AppendLine(stdout);
if (!string.IsNullOrWhiteSpace(stderr)) sb.AppendLine($"[STDERR]\n{stderr}");
return proc.ExitCode == 0
? ToolResult.Ok(sb.ToString())
: ToolResult.Ok($"테스트 실패 (exit code {proc.ExitCode}):\n{sb}");
}
catch (Exception ex)
{
return ToolResult.Fail($"테스트 실행 오류: {ex.Message}");
}
}
private static ToolResult AnalyzeTestOutput(JsonElement args)
{
var output = args.TryGetProperty("test_output", out var o) ? o.GetString() ?? "" : "";
if (string.IsNullOrEmpty(output))
return ToolResult.Fail("test_output이 필요합니다.");
var sb = new StringBuilder();
sb.AppendLine("테스트 결과 분석:");
var lines = output.Split('\n');
var failedCount = lines.Count(l => l.Contains("FAIL", StringComparison.OrdinalIgnoreCase) || l.Contains("FAILED"));
var passedCount = lines.Count(l => l.Contains("PASS", StringComparison.OrdinalIgnoreCase) || l.Contains("PASSED"));
var errorLines = lines.Where(l => l.Contains("Error", StringComparison.OrdinalIgnoreCase) || l.Contains("Exception")).Take(10).ToList();
sb.AppendLine($" 통과: {passedCount}개, 실패: {failedCount}개");
if (errorLines.Count > 0)
{
sb.AppendLine("\n주요 오류:");
foreach (var line in errorLines)
sb.AppendLine($" {line.Trim()}");
}
if (failedCount > 0)
sb.AppendLine("\n다음 단계: 실패한 테스트를 확인하고 관련 코드를 수정한 후 test_loop action=\"run\"으로 다시 실행하세요.");
else
sb.AppendLine("\n모든 테스트가 통과했습니다.");
return ToolResult.Ok(sb.ToString());
}
/// <summary>
/// auto_fix: 테스트 실행 → 실패 파싱 → 구조화된 수정 지침 반환.
/// LLM이 이 결과를 받아 코드를 수정하고 다시 auto_fix를 호출하는 반복 루프를 형성합니다.
/// </summary>
private static async Task<ToolResult> AutoFixAsync(AgentContext context, CancellationToken ct)
{
// 1. 테스트 실행
var runResult = await RunTestsAsync(context, ct);
var output = runResult.Output;
// 2. 테스트 전체 통과 → 성공 종료
var lines = output.Split('\n');
var failedCount = lines.Count(l =>
l.Contains("FAIL", StringComparison.OrdinalIgnoreCase) ||
l.Contains("FAILED", StringComparison.OrdinalIgnoreCase));
if (failedCount == 0 && runResult.Success && output.Contains("exit code: 0"))
return ToolResult.Ok("[AUTO_FIX: ALL_PASSED]\n모든 테스트가 통과했습니다. 수정 루프를 종료하세요.");
// 3. 실패 파싱 → 구조화된 실패 정보
var sb = new StringBuilder();
sb.AppendLine("[AUTO_FIX: FAILURES_DETECTED]");
sb.AppendLine($"실패 테스트 수: {failedCount}");
sb.AppendLine();
// 오류 메시지 추출
var errors = ExtractFailureDetails(lines);
if (errors.Count > 0)
{
sb.AppendLine("## 실패 상세:");
foreach (var err in errors.Take(10))
{
sb.AppendLine($"- 테스트: {err.TestName}");
if (!string.IsNullOrEmpty(err.FilePath))
sb.AppendLine($" 파일: {err.FilePath}:{err.Line}");
sb.AppendLine($" 오류: {err.Message}");
sb.AppendLine();
}
}
sb.AppendLine("## 수정 지침:");
sb.AppendLine("1. 위 오류 메시지에서 원인을 파악하세요");
sb.AppendLine("2. file_read로 관련 파일을 읽고 오류 원인을 확인하세요");
sb.AppendLine("3. file_edit로 코드를 수정하세요");
sb.AppendLine("4. test_loop action=\"auto_fix\"를 다시 호출하여 결과를 확인하세요");
sb.AppendLine("5. 모든 테스트가 통과할 때까지 반복하세요");
// 설정에서 최대 반복 횟수 안내
var app = System.Windows.Application.Current as App;
var maxIter = app?.SettingsService?.Settings.Llm.MaxTestFixIterations ?? 5;
sb.AppendLine($"\n※ 최대 수정 반복 횟수: {maxIter}회. 초과 시 사용자에게 보고하세요.");
// 전체 테스트 출력 (잘라서)
sb.AppendLine("\n## 전체 테스트 출력:");
var truncated = output.Length > 3000 ? output[..3000] + "\n... (출력 일부 생략)" : output;
sb.AppendLine(truncated);
return ToolResult.Ok(sb.ToString());
}
/// <summary>테스트 출력에서 실패 상세를 추출합니다.</summary>
private static List<FailureDetail> ExtractFailureDetails(string[] lines)
{
var failures = new List<FailureDetail>();
FailureDetail? current = null;
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i].Trim();
// .NET: "X 실패 테스트명 [시간]" 또는 "Failed 테스트명"
if (line.StartsWith("Failed ", StringComparison.OrdinalIgnoreCase) ||
line.Contains("FAIL!", StringComparison.OrdinalIgnoreCase))
{
current = new FailureDetail { TestName = line };
failures.Add(current);
}
// pytest: "FAILED test_file.py::test_name"
else if (line.StartsWith("FAILED ", StringComparison.OrdinalIgnoreCase))
{
current = new FailureDetail { TestName = line[7..].Trim() };
failures.Add(current);
}
// 파일 경로 + 줄 번호 패턴: "file.cs(123," or "file.py:123:"
else if (current != null && string.IsNullOrEmpty(current.FilePath))
{
var pathMatch = System.Text.RegularExpressions.Regex.Match(line,
@"([^\s]+\.\w+)[:\(](\d+)");
if (pathMatch.Success)
{
current.FilePath = pathMatch.Groups[1].Value;
current.Line = int.Parse(pathMatch.Groups[2].Value);
}
}
// 오류 메시지: "Assert.", "Error:", "Exception:"
else if (current != null && string.IsNullOrEmpty(current.Message) &&
(line.Contains("Assert", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Error", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Exception", StringComparison.OrdinalIgnoreCase)))
{
current.Message = line;
}
}
return failures;
}
private class FailureDetail
{
public string TestName { get; set; } = "";
public string FilePath { get; set; } = "";
public int Line { get; set; }
public string Message { get; set; } = "";
}
private static (string? Cmd, string Args) DetectTestCommand(string workFolder)
{
// .NET
if (Directory.EnumerateFiles(workFolder, "*.csproj", SearchOption.AllDirectories).Any() ||
Directory.EnumerateFiles(workFolder, "*.sln", SearchOption.TopDirectoryOnly).Any())
return ("dotnet", "test --no-build --verbosity normal");
// Python
if (File.Exists(Path.Combine(workFolder, "pytest.ini")) ||
File.Exists(Path.Combine(workFolder, "setup.py")) ||
Directory.EnumerateFiles(workFolder, "test_*.py", SearchOption.AllDirectories).Any())
return ("pytest", "--tb=short -q");
// Node.js
if (File.Exists(Path.Combine(workFolder, "package.json")))
return ("npm", "test -- --passWithNoTests");
// Go
if (Directory.EnumerateFiles(workFolder, "*_test.go", SearchOption.AllDirectories).Any())
return ("go", "test ./...");
return (null, "");
}
}

View File

@@ -0,0 +1,131 @@
using System.IO;
using System.Text;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 텍스트 파일 인코딩 감지/읽기/쓰기 유틸.
/// - 읽기: BOM + UTF-8 유효성 검사 기반 자동 감지
/// - 쓰기: 기존 파일 인코딩/UTF-8 BOM 여부를 최대한 보존
/// </summary>
public static class TextFileCodec
{
public readonly record struct TextReadResult(string Text, Encoding Encoding, bool HasBom);
static TextFileCodec()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
public static TextReadResult ReadAllText(string path)
{
var bytes = File.ReadAllBytes(path);
return Decode(bytes);
}
public static async Task<TextReadResult> ReadAllTextAsync(string path, CancellationToken ct = default)
{
var bytes = await File.ReadAllBytesAsync(path, ct);
return Decode(bytes);
}
public static string[] SplitLines(string text)
=> text.Split('\n');
public static Encoding ResolveWriteEncoding(Encoding sourceEncoding, bool sourceHasBom)
{
if (sourceEncoding.CodePage == Encoding.UTF8.CodePage)
return new UTF8Encoding(sourceHasBom);
return sourceEncoding;
}
public static async Task WriteAllTextAsync(string path, string content, Encoding encoding, CancellationToken ct = default)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir))
Directory.CreateDirectory(dir);
await using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, 4096, useAsync: true);
await using var writer = new StreamWriter(stream, encoding);
await writer.WriteAsync(content.AsMemory(), ct);
await writer.FlushAsync();
}
public static Encoding Utf8NoBom => new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
private static TextReadResult Decode(byte[] bytes)
{
var detected = DetectEncoding(bytes, out var bomLength, out var hasBom);
var payload = bomLength > 0 ? bytes[bomLength..] : bytes;
var text = detected.GetString(payload);
return new TextReadResult(text, detected, hasBom);
}
private static Encoding DetectEncoding(byte[] bytes, out int bomLength, out bool hasBom)
{
// UTF-8 BOM
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
{
bomLength = 3;
hasBom = true;
return Encoding.UTF8;
}
// UTF-16 LE BOM
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE)
{
bomLength = 2;
hasBom = true;
return Encoding.Unicode;
}
// UTF-16 BE BOM
if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF)
{
bomLength = 2;
hasBom = true;
return Encoding.BigEndianUnicode;
}
bomLength = 0;
hasBom = false;
if (IsValidUtf8(bytes))
return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
// 한국어 Windows 환경 호환 fallback
try { return Encoding.GetEncoding("euc-kr"); }
catch { return Encoding.Default; }
}
private static bool IsValidUtf8(byte[] bytes)
{
var i = 0;
while (i < bytes.Length)
{
if (bytes[i] <= 0x7F)
{
i++;
continue;
}
int extra;
if ((bytes[i] & 0xE0) == 0xC0) extra = 1;
else if ((bytes[i] & 0xF0) == 0xE0) extra = 2;
else if ((bytes[i] & 0xF8) == 0xF0) extra = 3;
else return false;
if (i + extra >= bytes.Length) return false;
for (var j = 1; j <= extra; j++)
{
if ((bytes[i + j] & 0xC0) != 0x80)
return false;
}
i += extra + 1;
}
return true;
}
}

View File

@@ -0,0 +1,266 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 긴 텍스트나 문서를 지정된 길이와 형식으로 요약하는 도구.
/// 텍스트를 청크 분할하여 단계적으로 요약합니다.
/// </summary>
public class TextSummarizeTool : IAgentTool
{
public string Name => "text_summarize";
public string Description =>
"Summarize long text or documents into a specified length and format. " +
"Supports: bullet points, paragraph, executive summary, technical summary. " +
"For very long texts, automatically chunks and summarizes progressively. " +
"Can summarize file contents or inline text.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["source"] = new()
{
Type = "string",
Description = "Text to summarize, OR file path (if starts with '/' or contains '\\' or '.'). " +
"For files: supports .txt, .md, .html, .csv, .json, .log"
},
["max_length"] = new()
{
Type = "integer",
Description = "Maximum summary length in characters. Default: 500"
},
["style"] = new()
{
Type = "string",
Description = "Summary style: bullet (bullet points), paragraph (flowing text), " +
"executive (key conclusions + action items), technical (detailed with terminology). Default: bullet",
Enum = ["bullet", "paragraph", "executive", "technical"]
},
["language"] = new()
{
Type = "string",
Description = "Output language: ko (Korean), en (English). Default: ko"
},
["focus"] = new()
{
Type = "string",
Description = "Optional focus area or keywords to emphasize in the summary."
},
["sections"] = new()
{
Type = "boolean",
Description = "If true, provide section-by-section summary instead of one overall summary. Default: false"
},
},
Required = ["source"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var source = args.GetProperty("source").GetString() ?? "";
var maxLength = args.TryGetProperty("max_length", out var mlEl) && mlEl.TryGetInt32(out var ml) ? ml : 500;
var style = args.TryGetProperty("style", out var stEl) ? stEl.GetString() ?? "bullet" : "bullet";
var language = args.TryGetProperty("language", out var langEl) ? langEl.GetString() ?? "ko" : "ko";
var focus = args.TryGetProperty("focus", out var focEl) ? focEl.GetString() ?? "" : "";
var bySections = args.TryGetProperty("sections", out var secEl) && secEl.GetBoolean();
string text;
// 파일 경로인지 확인
if (LooksLikeFilePath(source))
{
var fullPath = FileReadTool.ResolvePath(source, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
if (!File.Exists(fullPath))
return ToolResult.Fail($"파일 없음: {fullPath}");
text = (await TextFileCodec.ReadAllTextAsync(fullPath, ct)).Text;
// HTML 태그 제거
if (fullPath.EndsWith(".html", StringComparison.OrdinalIgnoreCase) ||
fullPath.EndsWith(".htm", StringComparison.OrdinalIgnoreCase))
{
text = StripHtmlTags(text);
}
}
else
{
text = source;
}
if (string.IsNullOrWhiteSpace(text))
return ToolResult.Fail("요약할 텍스트가 비어있습니다.");
// 텍스트 통계
var charCount = text.Length;
var lineCount = text.Split('\n').Length;
var wordCount = EstimateWordCount(text);
// 텍스트가 이미 충분히 짧으면 그대로 반환
if (charCount <= maxLength)
return ToolResult.Ok($"📝 텍스트가 이미 요약 기준 이하입니다 ({charCount}자).\n\n{text}");
// 청크 분할 (매우 긴 텍스트용)
var chunks = ChunkText(text, 3000);
var chunkSummaries = new List<string>();
foreach (var chunk in chunks)
{
var summary = ExtractKeyContent(chunk, maxLength / chunks.Count, style, focus);
chunkSummaries.Add(summary);
}
// 최종 요약 구성
var sb = new StringBuilder();
sb.AppendLine($"📝 텍스트 요약 (원문: {charCount:N0}자, {lineCount}줄, ~{wordCount}단어)");
sb.AppendLine();
if (bySections && chunks.Count > 1)
{
for (int i = 0; i < chunkSummaries.Count; i++)
{
sb.AppendLine($"### 섹션 {i + 1}/{chunkSummaries.Count}");
sb.AppendLine(chunkSummaries[i]);
sb.AppendLine();
}
}
else
{
var combined = string.Join("\n", chunkSummaries);
sb.AppendLine(FormatSummary(combined, style, language, focus));
}
var result = sb.ToString();
if (result.Length > maxLength + 500)
result = result[..(maxLength + 500)] + "\n...[요약 길이 초과로 생략]";
return ToolResult.Ok(result);
}
private static bool LooksLikeFilePath(string s)
{
if (string.IsNullOrEmpty(s)) return false;
if (s.Contains('\\') || s.Contains('/')) return true;
if (s.Length < 260 && System.Text.RegularExpressions.Regex.IsMatch(s, @"\.\w{1,5}$"))
return true;
return false;
}
private static string StripHtmlTags(string html)
{
var text = System.Text.RegularExpressions.Regex.Replace(html, @"<script[^>]*>.*?</script>", "",
System.Text.RegularExpressions.RegexOptions.Singleline);
text = System.Text.RegularExpressions.Regex.Replace(text, @"<style[^>]*>.*?</style>", "",
System.Text.RegularExpressions.RegexOptions.Singleline);
text = System.Text.RegularExpressions.Regex.Replace(text, @"<[^>]+>", " ");
text = System.Net.WebUtility.HtmlDecode(text);
return System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim();
}
private static int EstimateWordCount(string text)
{
var spaces = text.Count(c => c == ' ');
var koreanChars = text.Count(c => c >= 0xAC00 && c <= 0xD7A3);
return spaces + 1 + koreanChars / 3;
}
private static List<string> ChunkText(string text, int chunkSize)
{
var chunks = new List<string>();
var lines = text.Split('\n');
var currentChunk = new StringBuilder();
foreach (var line in lines)
{
if (currentChunk.Length + line.Length > chunkSize && currentChunk.Length > 0)
{
chunks.Add(currentChunk.ToString());
currentChunk.Clear();
}
currentChunk.AppendLine(line);
}
if (currentChunk.Length > 0)
chunks.Add(currentChunk.ToString());
return chunks;
}
private static string ExtractKeyContent(string text, int targetLength, string style, string focus)
{
// 텍스트에서 핵심 문장 추출 (간단한 추출 기반 요약)
var sentences = System.Text.RegularExpressions.Regex.Split(text, @"(?<=[.!?。\n])\s+")
.Where(s => s.Trim().Length > 10)
.ToList();
if (sentences.Count == 0) return text.Length > targetLength ? text[..targetLength] : text;
// 중요도 점수 계산
var scored = sentences.Select(s =>
{
double score = 0;
// 길이 적정성 (너무 짧지도 길지도 않은 문장 선호)
if (s.Length > 20 && s.Length < 200) score += 1;
// 숫자 포함 (데이터/통계)
if (System.Text.RegularExpressions.Regex.IsMatch(s, @"\d+")) score += 0.5;
// 키워드 포함
if (!string.IsNullOrEmpty(focus) && s.Contains(focus, StringComparison.OrdinalIgnoreCase)) score += 2;
// 위치 가중치 (첫 문장, 마지막 문장 중요)
var idx = sentences.IndexOf(s);
if (idx == 0 || idx == sentences.Count - 1) score += 1;
if (idx < 3) score += 0.5; // 앞쪽 문장 선호
// 핵심 키워드
if (s.Contains("결론") || s.Contains("요약") || s.Contains("핵심") ||
s.Contains("중요") || s.Contains("결과") || s.Contains("therefore") ||
s.Contains("conclusion") || s.Contains("key"))
score += 1.5;
return (Sentence: s.Trim(), Score: score);
})
.OrderByDescending(x => x.Score)
.ToList();
// 목표 길이에 맞게 문장 선택
var selected = new List<string>();
int currentLength = 0;
foreach (var (sentence, _) in scored)
{
if (currentLength + sentence.Length > targetLength && selected.Count > 0) break;
selected.Add(sentence);
currentLength += sentence.Length;
}
// 원문 순서로 재정렬
selected.Sort((a, b) => text.IndexOf(a).CompareTo(text.IndexOf(b)));
return string.Join("\n", selected);
}
private static string FormatSummary(string content, string style, string language, string focus)
{
switch (style)
{
case "bullet":
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return string.Join("\n", lines.Select(l => l.StartsWith("•") || l.StartsWith("-") ? l : $"• {l}"));
case "executive":
var sb = new StringBuilder();
sb.AppendLine("**핵심 요약**");
sb.AppendLine(content);
if (!string.IsNullOrEmpty(focus))
sb.AppendLine($"\n**주요 관심 영역 ({focus})**");
return sb.ToString();
case "technical":
return $"**기술 요약**\n{content}";
default: // paragraph
return content.Replace("\n\n", "\n").Replace("\n", " ").Trim();
}
}
}

View File

@@ -0,0 +1,191 @@
namespace AxCopilot.Services.Agent;
/// <summary>
/// 사용 가능한 에이전트 도구/스킬을 관리하는 레지스트리.
/// 도구 목록을 LLM function calling에 전달하고, 이름으로 도구를 찾습니다.
/// </summary>
public class ToolRegistry : IDisposable
{
private readonly Dictionary<string, IAgentTool> _tools = new(StringComparer.OrdinalIgnoreCase);
private readonly List<IDisposable> _ownedResources = new();
private readonly Dictionary<string, McpClientService> _mcpClients = new(StringComparer.OrdinalIgnoreCase);
/// <summary>등록된 모든 도구 목록.</summary>
public IReadOnlyCollection<IAgentTool> All => _tools.Values;
/// <summary>도구를 이름으로 찾습니다.</summary>
public IAgentTool? Get(string name) =>
_tools.TryGetValue(name, out var tool) ? tool : null;
/// <summary>도구를 등록합니다.</summary>
public void Register(IAgentTool tool) => _tools[tool.Name] = tool;
public IReadOnlyCollection<McpClientService> GetMcpClients() => _mcpClients.Values.ToList().AsReadOnly();
public async Task<int> RegisterMcpToolsAsync(IEnumerable<Models.McpServerEntry>? servers, CancellationToken ct = default)
{
if (servers == null) return 0;
var registered = 0;
foreach (var server in servers)
{
if (server == null || !server.Enabled) continue;
if (!string.Equals(server.Transport, "stdio", StringComparison.OrdinalIgnoreCase))
{
LogService.Warn($"MCP '{server.Name}': unsupported transport '{server.Transport}'.");
continue;
}
var client = new McpClientService(server);
if (!await client.ConnectAsync(ct).ConfigureAwait(false))
{
client.Dispose();
continue;
}
_ownedResources.Add(client);
_mcpClients[server.Name] = client;
foreach (var def in client.Tools)
{
Register(new McpTool(client, def));
registered++;
}
}
return registered;
}
/// <summary>비활성 도구를 제외한 활성 도구 목록을 반환합니다.</summary>
public IReadOnlyCollection<IAgentTool> GetActiveTools(IEnumerable<string>? disabledNames = null)
{
if (disabledNames == null) return All;
var disabled = new HashSet<string>(disabledNames, StringComparer.OrdinalIgnoreCase);
if (disabled.Count == 0) return All;
return _tools.Values.Where(t => !disabled.Contains(t.Name)).ToList().AsReadOnly();
}
/// <summary>IDisposable 도구를 모두 해제합니다.</summary>
public void Dispose()
{
foreach (var tool in _tools.Values)
{
if (tool is IDisposable disposable)
disposable.Dispose();
}
foreach (var resource in _ownedResources)
resource.Dispose();
_ownedResources.Clear();
_mcpClients.Clear();
_tools.Clear();
}
/// <summary>기본 도구 + 내장 스킬을 모두 등록한 레지스트리를 생성합니다.</summary>
public static ToolRegistry CreateDefault()
{
var registry = new ToolRegistry();
// 기본 도구 (파일/검색/프로세스)
registry.Register(new FileReadTool());
registry.Register(new FileWriteTool());
registry.Register(new FileEditTool());
registry.Register(new GlobTool());
registry.Register(new GrepTool());
registry.Register(new ProcessTool());
registry.Register(new FolderMapTool());
registry.Register(new DocumentReaderTool());
// 내장 스킬 (문서 생성)
registry.Register(new ExcelSkill());
registry.Register(new DocxSkill());
registry.Register(new CsvSkill());
registry.Register(new MarkdownSkill());
registry.Register(new HtmlSkill());
registry.Register(new ChartSkill());
registry.Register(new BatchSkill());
registry.Register(new PptxSkill());
// 멀티패스 문서 엔진
registry.Register(new DocumentPlannerTool());
registry.Register(new DocumentAssemblerTool());
// 문서 품질 검증 & 포맷 변환
registry.Register(new DocumentReviewTool());
registry.Register(new FormatConvertTool());
// Code 탭: 개발 환경 감지 & 빌드/테스트 & Git
registry.Register(new DevEnvDetectTool());
registry.Register(new BuildRunTool());
registry.Register(new GitTool());
registry.Register(new LspTool());
registry.Register(new SubAgentTool());
registry.Register(new WaitAgentsTool());
registry.Register(new CodeSearchTool());
registry.Register(new TestLoopTool());
// 코드 리뷰 + 프로젝트 규칙
registry.Register(new CodeReviewTool());
registry.Register(new ProjectRuleTool());
// 스킬 시스템
registry.Register(new SkillManagerTool());
// 에이전트 메모리
registry.Register(new MemoryTool());
// 데이터 처리 + 시스템 유틸리티
registry.Register(new JsonTool());
registry.Register(new RegexTool());
registry.Register(new DiffTool());
registry.Register(new ClipboardTool());
registry.Register(new NotifyTool());
registry.Register(new EnvTool());
registry.Register(new ZipTool());
registry.Register(new HttpTool());
registry.Register(new SqlTool());
registry.Register(new Base64Tool());
registry.Register(new HashTool());
registry.Register(new DateTimeTool());
// 코드 품질
registry.Register(new SnippetRunnerTool());
// 데이터 분석 + 문서 자동화
registry.Register(new DataPivotTool());
registry.Register(new TemplateRenderTool());
registry.Register(new TextSummarizeTool());
// 파일 모니터링 + 이미지 분석
registry.Register(new FileWatchTool());
registry.Register(new ImageAnalyzeTool());
// 파일 관리 + 메타데이터 + 멀티리드
registry.Register(new FileManageTool());
registry.Register(new FileInfoTool());
registry.Register(new MultiReadTool());
// 사용자 질문
registry.Register(new UserAskTool());
// MCP 리소스
registry.Register(new McpListResourcesTool(() => registry.GetMcpClients()));
registry.Register(new McpReadResourceTool(() => registry.GetMcpClients()));
// 외부 열기 + 수학 + XML + 인코딩
registry.Register(new OpenExternalTool());
registry.Register(new MathTool());
registry.Register(new XmlTool());
registry.Register(new EncodingTool());
// 태스크 추적
registry.Register(new TaskTrackerTool());
// 워크플로우 도구
registry.Register(new SuggestActionsTool());
registry.Register(new DiffPreviewTool());
registry.Register(new CheckpointTool());
registry.Register(new PlaybookTool());
return registry;
}
}

View File

@@ -0,0 +1,104 @@
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>에이전트가 사용자에게 질문하고 응답을 대기하는 도구.</summary>
public class UserAskTool : IAgentTool
{
public string Name => "user_ask";
public string Description =>
"Ask the user a question and wait for their response. " +
"Use when you need clarification, confirmation, or a choice from the user. " +
"Optionally provide predefined options for the user to pick from. " +
"The user can select from options OR type a custom response.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["question"] = new()
{
Type = "string",
Description = "The question to ask the user",
},
["options"] = new()
{
Type = "array",
Description = "Optional list of choices for the user (e.g. ['Option A', 'Option B'])",
Items = new() { Type = "string", Description = "Choice option" },
},
["default_value"] = new()
{
Type = "string",
Description = "Default value if user doesn't specify",
},
},
Required = ["question"],
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var question = args.GetProperty("question").GetString() ?? "";
var defaultVal = args.TryGetProperty("default_value", out var dv) ? dv.GetString() ?? "" : "";
var options = new List<string>();
if (args.TryGetProperty("options", out var opts) && opts.ValueKind == JsonValueKind.Array)
{
foreach (var o in opts.EnumerateArray())
{
var s = o.GetString();
if (!string.IsNullOrEmpty(s)) options.Add(s);
}
}
// UserAskCallback 사용 (커스텀 대화 상자)
if (context.UserAskCallback != null)
{
try
{
var response = await context.UserAskCallback(question, options, defaultVal);
if (response == null)
return ToolResult.Fail("사용자가 응답을 취소했습니다.");
return ToolResult.Ok($"사용자 응답: {response}");
}
catch (OperationCanceledException)
{
return ToolResult.Fail("사용자가 응답을 취소했습니다.");
}
catch (Exception ex)
{
return ToolResult.Fail($"사용자 입력 오류: {ex.Message}");
}
}
// 폴백: UserDecision 콜백
if (context.UserDecision != null)
{
try
{
var prompt = question;
if (!string.IsNullOrEmpty(defaultVal))
prompt += $"\n(기본값: {defaultVal})";
var effectiveOptions = options.Count > 0 ? options : new List<string> { "확인" };
var response = await context.UserDecision(prompt, effectiveOptions);
if (string.IsNullOrEmpty(response) && !string.IsNullOrEmpty(defaultVal))
response = defaultVal;
return ToolResult.Ok($"사용자 응답: {response}");
}
catch (OperationCanceledException)
{
return ToolResult.Fail("사용자가 응답을 취소했습니다.");
}
catch (Exception ex)
{
return ToolResult.Fail($"사용자 입력 오류: {ex.Message}");
}
}
return ToolResult.Fail("사용자 입력 콜백이 등록되지 않았습니다.");
}
}

View File

@@ -0,0 +1,189 @@
using System.IO;
using System.Text;
using System.Text.Json;
using System.Xml;
using System.Xml.Linq;
using System.Xml.XPath;
namespace AxCopilot.Services.Agent;
/// <summary>XML 파싱, XPath 쿼리, 변환 도구.</summary>
public class XmlTool : IAgentTool
{
public string Name => "xml_tool";
public string Description =>
"Parse and query XML documents. Actions: " +
"'parse' — parse XML file/string and return structure summary; " +
"'xpath' — evaluate XPath expression and return matching nodes; " +
"'to_json' — convert XML to JSON; " +
"'format' — pretty-print XML with indentation.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "Action: parse, xpath, to_json, format",
Enum = ["parse", "xpath", "to_json", "format"],
},
["path"] = new()
{
Type = "string",
Description = "XML file path (optional if 'xml' is provided)",
},
["xml"] = new()
{
Type = "string",
Description = "XML string (optional if 'path' is provided)",
},
["expression"] = new()
{
Type = "string",
Description = "XPath expression (for 'xpath' action)",
},
},
Required = ["action"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
var xmlStr = args.TryGetProperty("xml", out var x) ? x.GetString() ?? "" : "";
var rawPath = args.TryGetProperty("path", out var pv) ? pv.GetString() ?? "" : "";
var expression = args.TryGetProperty("expression", out var ex) ? ex.GetString() ?? "" : "";
try
{
// XML 소스 결정
if (string.IsNullOrEmpty(xmlStr) && !string.IsNullOrEmpty(rawPath))
{
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
if (!context.IsPathAllowed(path))
return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {path}"));
if (!File.Exists(path))
return Task.FromResult(ToolResult.Fail($"파일 없음: {path}"));
xmlStr = TextFileCodec.ReadAllText(path).Text;
}
if (string.IsNullOrEmpty(xmlStr))
return Task.FromResult(ToolResult.Fail("'xml' 또는 'path' 중 하나를 지정해야 합니다."));
var doc = XDocument.Parse(xmlStr);
return action switch
{
"parse" => Task.FromResult(ParseSummary(doc)),
"xpath" => Task.FromResult(EvalXPath(doc, expression)),
"to_json" => Task.FromResult(XmlToJson(doc)),
"format" => Task.FromResult(FormatXml(doc)),
_ => Task.FromResult(ToolResult.Fail($"Unknown action: {action}")),
};
}
catch (XmlException xe)
{
return Task.FromResult(ToolResult.Fail($"XML 파싱 오류: {xe.Message}"));
}
catch (Exception e)
{
return Task.FromResult(ToolResult.Fail($"XML 처리 오류: {e.Message}"));
}
}
private static ToolResult ParseSummary(XDocument doc)
{
var sb = new StringBuilder();
sb.AppendLine($"Root: {doc.Root?.Name.LocalName ?? "(none)"}");
if (doc.Root != null)
{
var ns = doc.Root.Name.Namespace;
if (!string.IsNullOrEmpty(ns.NamespaceName))
sb.AppendLine($"Namespace: {ns.NamespaceName}");
var elements = doc.Descendants().Count();
var attrs = doc.Descendants().SelectMany(e => e.Attributes()).Count();
sb.AppendLine($"Elements: {elements}");
sb.AppendLine($"Attributes: {attrs}");
// 최상위 자식 요소 나열 (최대 20개)
var children = doc.Root.Elements().Take(20).ToList();
sb.AppendLine($"Top-level children ({doc.Root.Elements().Count()}):");
foreach (var child in children)
sb.AppendLine($" <{child.Name.LocalName}> ({child.Elements().Count()} children)");
}
return ToolResult.Ok(sb.ToString());
}
private static ToolResult EvalXPath(XDocument doc, string xpath)
{
if (string.IsNullOrEmpty(xpath))
return ToolResult.Fail("XPath 'expression'이 필요합니다.");
var results = doc.XPathSelectElements(xpath).Take(50).ToList();
if (results.Count == 0)
return ToolResult.Ok("매칭 노드 없음.");
var sb = new StringBuilder();
sb.AppendLine($"매칭: {results.Count}개 노드");
foreach (var el in results)
{
var text = el.ToString();
if (text.Length > 500) text = text[..500] + "...";
sb.AppendLine(text);
}
return ToolResult.Ok(sb.ToString());
}
private static ToolResult XmlToJson(XDocument doc)
{
var json = System.Text.Json.JsonSerializer.Serialize(
XmlToDict(doc.Root!),
new JsonSerializerOptions { WriteIndented = true });
if (json.Length > 50_000) json = json[..50_000] + "\n... (truncated)";
return ToolResult.Ok(json);
}
private static Dictionary<string, object?> XmlToDict(XElement el)
{
var dict = new Dictionary<string, object?>();
foreach (var attr in el.Attributes())
dict[$"@{attr.Name.LocalName}"] = attr.Value;
var groups = el.Elements().GroupBy(e => e.Name.LocalName).ToList();
foreach (var g in groups)
{
var items = g.ToList();
if (items.Count == 1)
{
var child = items[0];
dict[g.Key] = child.HasElements ? XmlToDict(child) : (object?)child.Value;
}
else
{
dict[g.Key] = items.Select(c => c.HasElements ? (object)XmlToDict(c) : c.Value).ToList();
}
}
if (!el.HasElements && groups.Count == 0 && !string.IsNullOrEmpty(el.Value))
dict["#text"] = el.Value;
return dict;
}
private static ToolResult FormatXml(XDocument doc)
{
var sb = new StringBuilder();
using var writer = XmlWriter.Create(sb, new XmlWriterSettings
{
Indent = true,
IndentChars = " ",
OmitXmlDeclaration = false,
});
doc.WriteTo(writer);
writer.Flush();
var result = sb.ToString();
if (result.Length > 50_000) result = result[..50_000] + "\n... (truncated)";
return ToolResult.Ok(result);
}
}

View File

@@ -0,0 +1,167 @@
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").GetString() ?? "";
var zipPath = args.GetProperty("zip_path").GetString() ?? "";
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.TryGetProperty("source_path", out var sp))
return ToolResult.Fail("'source_path' is required for compress action");
var sourcePath = sp.GetString() ?? "";
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.TryGetProperty("dest_path", out var dp)
? dp.GetString() ?? "" : "";
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());
}
}