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,95 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public class AgentContext
{
public string WorkFolder { get; init; } = "";
public string Permission { get; init; } = "Ask";
public Dictionary<string, string> ToolPermissions { get; init; } = new Dictionary<string, string>();
public List<string> BlockedPaths { get; init; } = new List<string>();
public List<string> BlockedExtensions { get; init; } = new List<string>();
public string ActiveTab { get; init; } = "Chat";
public bool DevMode { get; init; }
public bool DevModeStepApproval { get; init; }
public Func<string, string, Task<bool>>? AskPermission { get; init; }
public Func<string, List<string>, Task<string?>>? UserDecision { get; init; }
public Func<string, List<string>, string, Task<string?>>? UserAskCallback { get; init; }
public bool IsPathAllowed(string path)
{
string fullPath = Path.GetFullPath(path);
string ext = Path.GetExtension(fullPath).ToLowerInvariant();
if (BlockedExtensions.Any((string e) => string.Equals(e, ext, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
foreach (string blockedPath in BlockedPaths)
{
string value = blockedPath.Replace("*", "");
if (!string.IsNullOrEmpty(value) && fullPath.Contains(value, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
if (!string.IsNullOrEmpty(WorkFolder))
{
string fullPath2 = Path.GetFullPath(WorkFolder);
if (!fullPath.StartsWith(fullPath2, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
return true;
}
public static string EnsureTimestampedPath(string fullPath)
{
string path = Path.GetDirectoryName(fullPath) ?? "";
string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fullPath);
string extension = Path.GetExtension(fullPath);
string text = DateTime.Now.ToString("yyyyMMdd_HHmm");
if (Regex.IsMatch(fileNameWithoutExtension, "_\\d{8}_\\d{4}$"))
{
return fullPath;
}
return Path.Combine(path, fileNameWithoutExtension + "_" + text + extension);
}
public async Task<bool> CheckWritePermissionAsync(string toolName, string filePath)
{
string effectivePerm = Permission;
if (ToolPermissions.TryGetValue(toolName, out string toolPerm))
{
effectivePerm = toolPerm;
}
if (string.Equals(effectivePerm, "Deny", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (string.Equals(effectivePerm, "Auto", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (AskPermission != null)
{
return await AskPermission(toolName, filePath);
}
return false;
}
}

View File

@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
namespace AxCopilot.Services.Agent;
public class AgentEvent
{
public DateTime Timestamp { get; init; } = DateTime.Now;
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;
public int StepCurrent { get; init; }
public int StepTotal { get; init; }
public List<string>? Steps { get; init; }
public long ElapsedMs { get; init; }
public int InputTokens { get; init; }
public int OutputTokens { get; init; }
public string? ToolInput { get; init; }
public int Iteration { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace AxCopilot.Services.Agent;
public enum AgentEventType
{
Thinking,
Planning,
StepStart,
StepDone,
ToolCall,
ToolResult,
SkillCall,
Error,
Complete,
Decision,
Paused,
Resumed
}

View File

@@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public static class AgentHookRunner
{
private const int MaxEnvValueLength = 4096;
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(CancellationToken))
{
List<HookExecutionResult> results = new List<HookExecutionResult>();
if (hooks == null || hooks.Count == 0)
{
return results;
}
foreach (AgentHookEntry hook in hooks)
{
if (hook.Enabled && string.Equals(hook.Timing, timing, StringComparison.OrdinalIgnoreCase) && (!(hook.ToolName != "*") || string.Equals(hook.ToolName, toolName, StringComparison.OrdinalIgnoreCase)))
{
results.Add(await ExecuteHookAsync(hook, toolName, timing, toolInput, toolOutput, success, workFolder, timeoutMs, ct));
}
}
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, Success: false, "스크립트 경로가 비어 있습니다.");
}
string scriptPath = Environment.ExpandEnvironmentVariables(hook.ScriptPath);
if (!File.Exists(scriptPath))
{
return new HookExecutionResult(hook.Name, Success: false, "스크립트를 찾을 수 없습니다: " + scriptPath);
}
string 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, Success: false, "지원하지 않는 스크립트 확장자: " + ext + " (.bat/.cmd/.ps1만 허용)");
}
if (!string.IsNullOrWhiteSpace(hook.Arguments))
{
arguments = arguments + " " + hook.Arguments;
}
ProcessStartInfo 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, 4096);
psi.EnvironmentVariables["AX_WORK_FOLDER"] = workFolder ?? "";
if (string.Equals(timing, "post", StringComparison.OrdinalIgnoreCase))
{
psi.EnvironmentVariables["AX_TOOL_OUTPUT"] = Truncate(toolOutput, 4096);
psi.EnvironmentVariables["AX_TOOL_SUCCESS"] = (success ? "true" : "false");
}
using Process process = new Process
{
StartInfo = psi
};
StringBuilder stdOut = new StringBuilder();
StringBuilder stdErr = new StringBuilder();
process.OutputDataReceived += delegate(object _, DataReceivedEventArgs e)
{
if (e.Data != null)
{
stdOut.AppendLine(e.Data);
}
};
process.ErrorDataReceived += delegate(object _, DataReceivedEventArgs e)
{
if (e.Data != null)
{
stdErr.AppendLine(e.Data);
}
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeoutMs);
try
{
await process.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
try
{
process.Kill(entireProcessTree: true);
}
catch
{
}
return new HookExecutionResult(hook.Name, Success: false, $"타임아웃 ({timeoutMs}ms 초과)");
}
int exitCode = process.ExitCode;
string output = stdOut.ToString().TrimEnd();
string error = stdErr.ToString().TrimEnd();
if (exitCode != 0)
{
return new HookExecutionResult(hook.Name, Success: false, $"종료 코드 {exitCode}: {(string.IsNullOrEmpty(error) ? output : error)}");
}
return new HookExecutionResult(hook.Name, Success: true, string.IsNullOrEmpty(output) ? "(정상 완료)" : output);
}
catch (Exception ex2)
{
Exception ex3 = ex2;
return new HookExecutionResult(hook.Name, Success: false, "훅 실행 예외: " + ex3.Message);
}
}
private static string Truncate(string? value, int maxLen)
{
return string.IsNullOrEmpty(value) ? "" : ((value.Length <= maxLen) ? value : value.Substring(0, maxLen));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Action to perform"
};
int num = 5;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "b64encode";
span[1] = "b64decode";
span[2] = "urlencode";
span[3] = "urldecode";
span[4] = "b64file";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["text"] = new ToolProperty
{
Type = "string",
Description = "Text to encode/decode"
};
dictionary["file_path"] = new ToolProperty
{
Type = "string",
Description = "File path for b64file action"
};
toolParameterSchema.Properties = dictionary;
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "action";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string text = args.GetProperty("action").GetString() ?? "";
JsonElement value;
string text2 = (args.TryGetProperty("text", out value) ? (value.GetString() ?? "") : "");
try
{
if (1 == 0)
{
}
ToolResult result = text switch
{
"b64encode" => ToolResult.Ok(Convert.ToBase64String(Encoding.UTF8.GetBytes(text2))),
"b64decode" => ToolResult.Ok(Encoding.UTF8.GetString(Convert.FromBase64String(text2))),
"urlencode" => ToolResult.Ok(Uri.EscapeDataString(text2)),
"urldecode" => ToolResult.Ok(Uri.UnescapeDataString(text2)),
"b64file" => EncodeFile(args, context),
_ => ToolResult.Fail("Unknown action: " + text),
};
if (1 == 0)
{
}
return Task.FromResult(result);
}
catch (FormatException)
{
return Task.FromResult(ToolResult.Fail("Invalid Base64 string"));
}
catch (Exception ex2)
{
return Task.FromResult(ToolResult.Fail("오류: " + ex2.Message));
}
}
private static ToolResult EncodeFile(JsonElement args, AgentContext context)
{
if (!args.TryGetProperty("file_path", out var value))
{
return ToolResult.Fail("'file_path' is required for b64file action");
}
string text = value.GetString() ?? "";
if (!Path.IsPathRooted(text))
{
text = Path.Combine(context.WorkFolder, text);
}
if (!File.Exists(text))
{
return ToolResult.Fail("File not found: " + text);
}
FileInfo fileInfo = new FileInfo(text);
if (fileInfo.Length > 5242880)
{
return ToolResult.Fail($"File too large ({fileInfo.Length / 1024}KB). Max 5MB.");
}
byte[] inArray = File.ReadAllBytes(text);
string text2 = Convert.ToBase64String(inArray);
if (text2.Length > 10000)
{
return ToolResult.Ok($"Base64 ({text2.Length} chars, first 500):\n{text2.Substring(0, 500)}...");
}
return ToolResult.Ok(text2);
}
}

View File

@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public class BatchSkill : IAgentTool
{
private static readonly string[] BlockedCommands = new string[23]
{
"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 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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "Output file path (.bat or .ps1). Relative to work folder."
},
["content"] = new ToolProperty
{
Type = "string",
Description = "Script content. Each line should have Korean comments explaining the command."
},
["description"] = new ToolProperty
{
Type = "string",
Description = "Brief description of what this script does."
}
}
};
int num = 2;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "path";
span[1] = "content";
obj.Required = list;
return obj;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string path = args.GetProperty("path").GetString() ?? "";
string content = args.GetProperty("content").GetString() ?? "";
JsonElement d;
string desc = (args.TryGetProperty("description", out d) ? (d.GetString() ?? "") : "");
string fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork")
{
fullPath = AgentContext.EnsureTimestampedPath(fullPath);
}
string 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);
}
string contentLower = content.ToLowerInvariant();
string[] blockedCommands = BlockedCommands;
foreach (string blocked in blockedCommands)
{
if (contentLower.Contains(blocked.ToLowerInvariant()))
{
return ToolResult.Fail("시스템 수준 명령이 포함되어 차단됨: " + blocked.Trim());
}
}
if (!(await context.CheckWritePermissionAsync(Name, fullPath)))
{
return ToolResult.Fail("쓰기 권한 거부: " + fullPath);
}
try
{
string dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
StringBuilder sb = new StringBuilder();
if (!string.IsNullOrEmpty(desc))
{
string commentPrefix = ((ext == ".ps1") ? "#" : "REM");
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(9, 2, stringBuilder);
handler.AppendFormatted(commentPrefix);
handler.AppendLiteral(" === ");
handler.AppendFormatted(desc);
handler.AppendLiteral(" ===");
stringBuilder2.AppendLine(ref handler);
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(32, 1, stringBuilder);
handler.AppendFormatted(commentPrefix);
handler.AppendLiteral(" 이 스크립트는 AX Copilot에 의해 생성되었습니다.");
stringBuilder3.AppendLine(ref handler);
stringBuilder = sb;
StringBuilder stringBuilder4 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(20, 1, stringBuilder);
handler.AppendFormatted(commentPrefix);
handler.AppendLiteral(" 실행 전 내용을 반드시 확인하세요.");
stringBuilder4.AppendLine(ref handler);
sb.AppendLine();
}
sb.Append(content);
await File.WriteAllTextAsync(fullPath, sb.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: 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,263 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
namespace AxCopilot.Services.Agent;
public class BuildRunTool : IAgentTool
{
private record ProjectInfo(string Type, string Marker, string BuildCommand, string TestCommand, string RunCommand, string LintCommand, string FormatCommand);
private static readonly string[] DangerousPatterns = new string[16]
{
"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 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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Action to perform: detect, build, test, run, lint, format, custom"
};
int num = 7;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "detect";
span[1] = "build";
span[2] = "test";
span[3] = "run";
span[4] = "lint";
span[5] = "format";
span[6] = "custom";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["command"] = new ToolProperty
{
Type = "string",
Description = "Custom command to execute (required for action='custom')"
};
dictionary["project_path"] = new ToolProperty
{
Type = "string",
Description = "Subdirectory within work folder (optional, defaults to work folder root)"
};
toolParameterSchema.Properties = dictionary;
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "action";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string action = args.GetProperty("action").GetString() ?? "detect";
JsonElement cmd;
string customCmd = (args.TryGetProperty("command", out cmd) ? (cmd.GetString() ?? "") : "");
JsonElement pp;
string subPath = (args.TryGetProperty("project_path", out pp) ? (pp.GetString() ?? "") : "");
string workDir = context.WorkFolder;
if (!string.IsNullOrEmpty(subPath))
{
workDir = Path.Combine(workDir, subPath);
}
if (string.IsNullOrEmpty(workDir) || !Directory.Exists(workDir))
{
return ToolResult.Fail("작업 폴더가 유효하지 않습니다: " + workDir);
}
ProjectInfo 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'으로 직접 명령을 지정하세요.");
}
if (1 == 0)
{
}
string text = 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 (1 == 0)
{
}
command = text;
if (command == null)
{
return ToolResult.Fail($"이 프로젝트 타입({project.Type})에서 '{action}' 작업은 지원되지 않습니다.");
}
}
string[] dangerousPatterns = DangerousPatterns;
foreach (string pattern in dangerousPatterns)
{
if (command.Contains(pattern, StringComparison.OrdinalIgnoreCase))
{
return ToolResult.Fail("차단된 명령 패턴: " + pattern);
}
}
if (!(await context.CheckWritePermissionAsync(Name, workDir)))
{
return ToolResult.Fail("빌드 실행 권한이 거부되었습니다.");
}
int timeout = (Application.Current as App)?.SettingsService?.Settings.Llm.Code.BuildTimeout ?? 120;
try
{
ProcessStartInfo psi = new ProcessStartInfo("cmd.exe", "/C " + command)
{
WorkingDirectory = workDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
using Process proc = Process.Start(psi);
if (proc == null)
{
return ToolResult.Fail("프로세스 시작 실패");
}
Task<string> stdoutTask = proc.StandardOutput.ReadToEndAsync(cts.Token);
Task<string> stderrTask = proc.StandardError.ReadToEndAsync(cts.Token);
await proc.WaitForExitAsync(cts.Token);
string stdout = await stdoutTask;
string stderr = await stderrTask;
if (stdout.Length > 8000)
{
stdout = stdout.Substring(0, 8000) + "\n... (출력 잘림)";
}
if (stderr.Length > 4000)
{
stderr = stderr.Substring(0, 4000) + "\n... (출력 잘림)";
}
StringBuilder sb = new StringBuilder();
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(3, 2, stringBuilder);
handler.AppendLiteral("[");
handler.AppendFormatted(action);
handler.AppendLiteral("] ");
handler.AppendFormatted(command);
stringBuilder2.AppendLine(ref handler);
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(13, 1, stringBuilder);
handler.AppendLiteral("[Exit code: ");
handler.AppendFormatted(proc.ExitCode);
handler.AppendLiteral("]");
stringBuilder3.AppendLine(ref handler);
if (!string.IsNullOrWhiteSpace(stdout))
{
sb.AppendLine(stdout);
}
if (!string.IsNullOrWhiteSpace(stderr))
{
stringBuilder = sb;
StringBuilder stringBuilder4 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder);
handler.AppendLiteral("[stderr]\n");
handler.AppendFormatted(stderr);
stringBuilder4.AppendLine(ref handler);
}
return (proc.ExitCode == 0) ? ToolResult.Ok(sb.ToString()) : ToolResult.Fail(sb.ToString());
}
catch (OperationCanceledException)
{
return ToolResult.Fail($"빌드 타임아웃 ({timeout}초 초과): {command}");
}
catch (Exception ex2)
{
return ToolResult.Fail("실행 오류: " + ex2.Message);
}
}
private static ProjectInfo? DetectProjectType(string dir)
{
if (Directory.GetFiles(dir, "*.sln").Length != 0)
{
return new ProjectInfo(".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 ProjectInfo(".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 ProjectInfo("Maven", "pom.xml", "mvn compile", "mvn test", "mvn exec:java", "mvn checkstyle:check", "");
}
if (Directory.GetFiles(dir, "build.gradle*").Length != 0)
{
return new ProjectInfo("Gradle", "build.gradle", "gradle build", "gradle test", "gradle run", "gradle check", "");
}
if (File.Exists(Path.Combine(dir, "package.json")))
{
return new ProjectInfo("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 ProjectInfo("CMake", "CMakeLists.txt", "cmake --build build", "ctest --test-dir build", "", "", "");
}
if (File.Exists(Path.Combine(dir, "pyproject.toml")))
{
return new ProjectInfo("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 ProjectInfo("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 ProjectInfo("Make", "Makefile", "make", "make test", "make run", "make lint", "make format");
}
if (Directory.GetFiles(dir, "*.py").Length != 0)
{
return new ProjectInfo("Python Scripts", "*.py", "", "python -m pytest", "python", "python -m ruff check .", "python -m black .");
}
return null;
}
}

View File

@@ -0,0 +1,809 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public class ChartSkill : IAgentTool
{
private sealed class Dataset
{
public string Name { get; init; } = "";
public List<double> Values { get; init; } = new List<double>();
public string Color { get; init; } = "#4B5EFC";
}
private static readonly string[] Palette = new string[10] { "#4B5EFC", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#06B6D4", "#EC4899", "#84CC16", "#F97316", "#6366F1" };
private const string ChartCss = "\n/* Vertical Bar Chart */\n.vbar-chart { margin: 16px 0; }\n.vbar-bars { display: flex; align-items: flex-end; gap: 8px; height: 220px; padding: 0 8px; border-bottom: 2px solid #E5E7EB; }\n.vbar-group { flex: 1; display: flex; gap: 3px; align-items: flex-end; position: relative; }\n.vbar-bar { flex: 1; min-width: 18px; border-radius: 4px 4px 0 0; transition: opacity 0.2s; cursor: default; }\n.vbar-bar:hover { opacity: 0.8; }\n.vbar-label { text-align: center; font-size: 11px; color: #6B7280; margin-top: 6px; position: absolute; bottom: -24px; left: 0; right: 0; }\n\n/* Horizontal Bar Chart */\n.hbar-chart { margin: 12px 0; }\n.hbar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }\n.hbar-label { min-width: 80px; text-align: right; font-size: 12px; color: #374151; font-weight: 500; }\n.hbar-track { flex: 1; height: 22px; background: #F3F4F6; border-radius: 6px; overflow: hidden; }\n.hbar-fill { height: 100%; border-radius: 6px; transition: width 0.6s ease; }\n.hbar-value { min-width: 50px; font-size: 12px; color: #6B7280; font-weight: 600; }\n\n/* Line/Area Chart */\n.line-chart-svg { width: 100%; max-width: 600px; height: auto; }\n\n/* Legend */\n.chart-legend { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 12px; padding-top: 8px; border-top: 1px solid #F3F4F6; }\n.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #374151; }\n.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }\n\n/* Pie Legend */\n.pie-legend { display: flex; flex-direction: column; gap: 6px; }\n.pie-legend-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #374151; }\n";
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "Output file path (.html). Relative to work folder."
},
["title"] = new ToolProperty
{
Type = "string",
Description = "Document title"
},
["charts"] = new ToolProperty
{
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 ToolProperty
{
Type = "string",
Description = "Design mood: modern, professional, creative, dark, dashboard, etc. Default: dashboard"
},
["layout"] = new ToolProperty
{
Type = "string",
Description = "Chart layout: 'single' (one per row) or 'grid' (2-column grid). Default: single"
}
}
};
int num = 3;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "path";
span[1] = "title";
span[2] = "charts";
obj.Required = list;
return obj;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string path = args.GetProperty("path").GetString() ?? "chart.html";
string title = args.GetProperty("title").GetString() ?? "Chart";
JsonElement m;
string mood = (args.TryGetProperty("mood", out m) ? (m.GetString() ?? "dashboard") : "dashboard");
JsonElement l;
string layout = (args.TryGetProperty("layout", out l) ? (l.GetString() ?? "single") : "single");
string 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);
}
string 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 파라미터가 필요합니다 (배열 형식).");
}
int chartCount = chartsEl.GetArrayLength();
StringBuilder body = new StringBuilder();
StringBuilder stringBuilder = body;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder);
handler.AppendLiteral("<h1>");
handler.AppendFormatted(Escape(title));
handler.AppendLiteral("</h1>");
stringBuilder.AppendLine(ref handler);
if (layout == "grid" && chartCount > 1)
{
body.AppendLine("<div class=\"grid-2\">");
}
int chartIdx = 0;
foreach (JsonElement chartEl in chartsEl.EnumerateArray())
{
string 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>");
}
string css = TemplateService.GetCss(mood) + "\n\n/* Vertical Bar Chart */\n.vbar-chart { margin: 16px 0; }\n.vbar-bars { display: flex; align-items: flex-end; gap: 8px; height: 220px; padding: 0 8px; border-bottom: 2px solid #E5E7EB; }\n.vbar-group { flex: 1; display: flex; gap: 3px; align-items: flex-end; position: relative; }\n.vbar-bar { flex: 1; min-width: 18px; border-radius: 4px 4px 0 0; transition: opacity 0.2s; cursor: default; }\n.vbar-bar:hover { opacity: 0.8; }\n.vbar-label { text-align: center; font-size: 11px; color: #6B7280; margin-top: 6px; position: absolute; bottom: -24px; left: 0; right: 0; }\n\n/* Horizontal Bar Chart */\n.hbar-chart { margin: 12px 0; }\n.hbar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }\n.hbar-label { min-width: 80px; text-align: right; font-size: 12px; color: #374151; font-weight: 500; }\n.hbar-track { flex: 1; height: 22px; background: #F3F4F6; border-radius: 6px; overflow: hidden; }\n.hbar-fill { height: 100%; border-radius: 6px; transition: width 0.6s ease; }\n.hbar-value { min-width: 50px; font-size: 12px; color: #6B7280; font-weight: 600; }\n\n/* Line/Area Chart */\n.line-chart-svg { width: 100%; max-width: 600px; height: auto; }\n\n/* Legend */\n.chart-legend { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 12px; padding-top: 8px; border-top: 1px solid #F3F4F6; }\n.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #374151; }\n.legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }\n\n/* Pie Legend */\n.pie-legend { display: flex; flex-direction: column; gap: 6px; }\n.pie-legend-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #374151; }\n";
string html = $"<!DOCTYPE html>\n<html lang=\"ko\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>{Escape(title)}</title>\n<style>\n{css}\n</style>\n</head>\n<body>\n<div class=\"container\">\n{body}\n</div>\n</body>\n</html>";
await File.WriteAllTextAsync(fullPath, html, Encoding.UTF8, ct);
return ToolResult.Ok($"차트 문서 생성 완료: {fullPath} ({chartCount}개 차트)", fullPath);
}
private string RenderChart(JsonElement chart, int idx)
{
JsonElement value;
string text = (chart.TryGetProperty("type", out value) ? (value.GetString() ?? "bar") : "bar");
JsonElement value2;
string text2 = (chart.TryGetProperty("title", out value2) ? (value2.GetString() ?? "") : "");
JsonElement value3;
string unit = (chart.TryGetProperty("unit", out value3) ? (value3.GetString() ?? "") : "");
List<string> labels = ParseStringArray(chart, "labels");
List<Dataset> list = ParseDatasets(chart);
StringBuilder stringBuilder = new StringBuilder();
if (!string.IsNullOrEmpty(text2))
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(37, 1, stringBuilder2);
handler.AppendLiteral("<h3 style=\"margin-bottom:12px;\">");
handler.AppendFormatted(Escape(text2));
handler.AppendLiteral("</h3>");
stringBuilder3.AppendLine(ref handler);
}
switch (text)
{
case "bar":
stringBuilder.Append(RenderBarChart(labels, list, unit, horizontal: false));
break;
case "horizontal_bar":
stringBuilder.Append(RenderBarChart(labels, list, unit, horizontal: true));
break;
case "stacked_bar":
stringBuilder.Append(RenderStackedBar(labels, list, unit));
break;
case "line":
case "area":
stringBuilder.Append(RenderLineChart(labels, list, unit, text == "area"));
break;
case "pie":
case "donut":
stringBuilder.Append(RenderPieChart(labels, list, text == "donut"));
break;
case "progress":
stringBuilder.Append(RenderProgressChart(labels, list, unit));
break;
case "comparison":
stringBuilder.Append(RenderComparisonChart(labels, list, unit));
break;
case "radar":
stringBuilder.Append(RenderRadarChart(labels, list));
break;
default:
stringBuilder.Append(RenderBarChart(labels, list, unit, horizontal: false));
break;
}
if (list.Count > 1)
{
stringBuilder.AppendLine("<div class=\"chart-legend\">");
foreach (Dataset item in list)
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(85, 2, stringBuilder2);
handler.AppendLiteral("<span class=\"legend-item\"><span class=\"legend-dot\" style=\"background:");
handler.AppendFormatted(item.Color);
handler.AppendLiteral("\"></span>");
handler.AppendFormatted(Escape(item.Name));
handler.AppendLiteral("</span>");
stringBuilder4.AppendLine(ref handler);
}
stringBuilder.AppendLine("</div>");
}
return stringBuilder.ToString();
}
private static string RenderBarChart(List<string> labels, List<Dataset> datasets, string unit, bool horizontal)
{
double num = datasets.SelectMany((Dataset d) => d.Values).DefaultIfEmpty(1.0).Max();
if (num <= 0.0)
{
num = 1.0;
}
StringBuilder stringBuilder = new StringBuilder();
if (horizontal)
{
stringBuilder.AppendLine("<div class=\"hbar-chart\">");
for (int num2 = 0; num2 < labels.Count; num2++)
{
double num3 = ((datasets.Count > 0 && num2 < datasets[0].Values.Count) ? datasets[0].Values[num2] : 0.0);
int value = (int)(num3 / num * 100.0);
string value2 = ((datasets.Count > 0) ? datasets[0].Color : Palette[0]);
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(54, 1, stringBuilder2);
handler.AppendLiteral("<div class=\"hbar-row\"><span class=\"hbar-label\">");
handler.AppendFormatted(Escape(labels[num2]));
handler.AppendLiteral("</span>");
stringBuilder3.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(88, 2, stringBuilder2);
handler.AppendLiteral("<div class=\"hbar-track\"><div class=\"hbar-fill\" style=\"width:");
handler.AppendFormatted(value);
handler.AppendLiteral("%;background:");
handler.AppendFormatted(value2);
handler.AppendLiteral(";\"></div></div>");
stringBuilder4.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(38, 2, stringBuilder2);
handler.AppendLiteral("<span class=\"hbar-value\">");
handler.AppendFormatted(num3, "G");
handler.AppendFormatted(unit);
handler.AppendLiteral("</span></div>");
stringBuilder5.AppendLine(ref handler);
}
stringBuilder.AppendLine("</div>");
}
else
{
stringBuilder.AppendLine("<div class=\"vbar-chart\">");
stringBuilder.AppendLine("<div class=\"vbar-bars\">");
for (int num4 = 0; num4 < labels.Count; num4++)
{
stringBuilder.AppendLine("<div class=\"vbar-group\">");
StringBuilder stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler;
foreach (Dataset dataset in datasets)
{
double num5 = ((num4 < dataset.Values.Count) ? dataset.Values[num4] : 0.0);
int value3 = (int)(num5 / num * 100.0);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(67, 4, stringBuilder2);
handler.AppendLiteral("<div class=\"vbar-bar\" style=\"height:");
handler.AppendFormatted(value3);
handler.AppendLiteral("%;background:");
handler.AppendFormatted(dataset.Color);
handler.AppendLiteral(";\" title=\"");
handler.AppendFormatted(num5, "G");
handler.AppendFormatted(unit);
handler.AppendLiteral("\"></div>");
stringBuilder6.AppendLine(ref handler);
}
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(30, 1, stringBuilder2);
handler.AppendLiteral("<div class=\"vbar-label\">");
handler.AppendFormatted(Escape(labels[num4]));
handler.AppendLiteral("</div>");
stringBuilder7.AppendLine(ref handler);
stringBuilder.AppendLine("</div>");
}
stringBuilder.AppendLine("</div></div>");
}
return stringBuilder.ToString();
}
private static string RenderStackedBar(List<string> labels, List<Dataset> datasets, string unit)
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("<div class=\"hbar-chart\">");
int i;
for (i = 0; i < labels.Count; i++)
{
double num = datasets.Sum((Dataset ds) => (i < ds.Values.Count) ? ds.Values[i] : 0.0);
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(54, 1, stringBuilder2);
handler.AppendLiteral("<div class=\"hbar-row\"><span class=\"hbar-label\">");
handler.AppendFormatted(Escape(labels[i]));
handler.AppendLiteral("</span>");
stringBuilder3.AppendLine(ref handler);
stringBuilder.AppendLine("<div class=\"hbar-track\" style=\"display:flex;\">");
foreach (Dataset dataset in datasets)
{
double num2 = ((i < dataset.Values.Count) ? dataset.Values[i] : 0.0);
int value = ((num > 0.0) ? ((int)(num2 / num * 100.0)) : 0);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(63, 5, stringBuilder2);
handler.AppendLiteral("<div style=\"width:");
handler.AppendFormatted(value);
handler.AppendLiteral("%;background:");
handler.AppendFormatted(dataset.Color);
handler.AppendLiteral(";height:100%;\" title=\"");
handler.AppendFormatted(dataset.Name);
handler.AppendLiteral(": ");
handler.AppendFormatted(num2, "G");
handler.AppendFormatted(unit);
handler.AppendLiteral("\"></div>");
stringBuilder4.AppendLine(ref handler);
}
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(44, 2, stringBuilder2);
handler.AppendLiteral("</div><span class=\"hbar-value\">");
handler.AppendFormatted(num, "G");
handler.AppendFormatted(unit);
handler.AppendLiteral("</span></div>");
stringBuilder5.AppendLine(ref handler);
}
stringBuilder.AppendLine("</div>");
return stringBuilder.ToString();
}
private static string RenderLineChart(List<string> labels, List<Dataset> datasets, string unit, bool isArea)
{
List<double> source = datasets.SelectMany((Dataset d) => d.Values).ToList();
double num = source.DefaultIfEmpty(1.0).Max();
double num2 = source.DefaultIfEmpty(0.0).Min();
if (num <= num2)
{
num = num2 + 1.0;
}
int num3 = 600;
int num4 = 300;
int num5 = 50;
int num6 = 20;
int num7 = 20;
int num8 = 40;
int num9 = num3 - num5 - num6;
int num10 = num4 - num7 - num8;
int count = labels.Count;
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(80, 2, stringBuilder2);
handler.AppendLiteral("<svg viewBox=\"0 0 ");
handler.AppendFormatted(num3);
handler.AppendLiteral(" ");
handler.AppendFormatted(num4);
handler.AppendLiteral("\" class=\"line-chart-svg\" preserveAspectRatio=\"xMidYMid meet\">");
stringBuilder3.AppendLine(ref handler);
for (int num11 = 0; num11 <= 4; num11++)
{
double num12 = (double)(num7 + num10) - (double)(num10 * num11) / 4.0;
double value = num2 + (num - num2) * (double)num11 / 4.0;
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(65, 4, stringBuilder2);
handler.AppendLiteral("<line x1=\"");
handler.AppendFormatted(num5);
handler.AppendLiteral("\" y1=\"");
handler.AppendFormatted(num12, "F0");
handler.AppendLiteral("\" x2=\"");
handler.AppendFormatted(num3 - num6);
handler.AppendLiteral("\" y2=\"");
handler.AppendFormatted(num12, "F0");
handler.AppendLiteral("\" stroke=\"#E5E7EB\" stroke-width=\"1\"/>");
stringBuilder4.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(71, 4, stringBuilder2);
handler.AppendLiteral("<text x=\"");
handler.AppendFormatted(num5 - 8);
handler.AppendLiteral("\" y=\"");
handler.AppendFormatted(num12 + 4.0, "F0");
handler.AppendLiteral("\" text-anchor=\"end\" fill=\"#6B7280\" font-size=\"11\">");
handler.AppendFormatted(value, "G3");
handler.AppendFormatted(unit);
handler.AppendLiteral("</text>");
stringBuilder5.AppendLine(ref handler);
}
for (int num13 = 0; num13 < count; num13++)
{
double value2 = (double)num5 + ((count > 1) ? ((double)(num9 * num13) / (double)(count - 1)) : ((double)num9 / 2.0));
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(74, 3, stringBuilder2);
handler.AppendLiteral("<text x=\"");
handler.AppendFormatted(value2, "F0");
handler.AppendLiteral("\" y=\"");
handler.AppendFormatted(num4 - 8);
handler.AppendLiteral("\" text-anchor=\"middle\" fill=\"#6B7280\" font-size=\"11\">");
handler.AppendFormatted(Escape(labels[num13]));
handler.AppendLiteral("</text>");
stringBuilder6.AppendLine(ref handler);
}
foreach (Dataset dataset in datasets)
{
List<(double, double)> list = new List<(double, double)>();
for (int num14 = 0; num14 < Math.Min(count, dataset.Values.Count); num14++)
{
double item = (double)num5 + ((count > 1) ? ((double)(num9 * num14) / (double)(count - 1)) : ((double)num9 / 2.0));
double item2 = (double)(num7 + num10) - (dataset.Values[num14] - num2) / (num - num2) * (double)num10;
list.Add((item, item2));
}
string text = string.Join(" ", list.Select<(double, double), string>(((double x, double y) p, int i) => $"{((i == 0) ? "M" : "L")}{p.x:F1},{p.y:F1}"));
if (isArea && list.Count > 1)
{
string value3 = text + $" L{list.Last().Item1:F1},{num7 + num10} L{list.First().Item1:F1},{num7 + num10} Z";
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(35, 2, stringBuilder2);
handler.AppendLiteral("<path d=\"");
handler.AppendFormatted(value3);
handler.AppendLiteral("\" fill=\"");
handler.AppendFormatted(dataset.Color);
handler.AppendLiteral("\" opacity=\"0.15\"/>");
stringBuilder7.AppendLine(ref handler);
}
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder8 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(100, 2, stringBuilder2);
handler.AppendLiteral("<path d=\"");
handler.AppendFormatted(text);
handler.AppendLiteral("\" fill=\"none\" stroke=\"");
handler.AppendFormatted(dataset.Color);
handler.AppendLiteral("\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>");
stringBuilder8.AppendLine(ref handler);
foreach (var item5 in list)
{
double item3 = item5.Item1;
double item4 = item5.Item2;
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder9 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(67, 3, stringBuilder2);
handler.AppendLiteral("<circle cx=\"");
handler.AppendFormatted(item3, "F1");
handler.AppendLiteral("\" cy=\"");
handler.AppendFormatted(item4, "F1");
handler.AppendLiteral("\" r=\"4\" fill=\"");
handler.AppendFormatted(dataset.Color);
handler.AppendLiteral("\" stroke=\"white\" stroke-width=\"2\"/>");
stringBuilder9.AppendLine(ref handler);
}
}
stringBuilder.AppendLine("</svg>");
return stringBuilder.ToString();
}
private static string RenderPieChart(List<string> labels, List<Dataset> datasets, bool isDonut)
{
List<double> list = ((datasets.Count > 0) ? datasets[0].Values : new List<double>());
double num = list.Sum();
if (num <= 0.0)
{
num = 1.0;
}
int num2 = 150;
int num3 = 150;
int num4 = 120;
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("<div style=\"display:flex;align-items:center;gap:24px;flex-wrap:wrap;\">");
stringBuilder.AppendLine("<svg viewBox=\"0 0 300 300\" width=\"260\" height=\"260\">");
double num5 = -90.0;
for (int i = 0; i < Math.Min(list.Count, labels.Count); i++)
{
double num6 = list[i] / num;
double num7 = num6 * 360.0;
double num8 = num5 + num7;
double value = (double)num2 + (double)num4 * Math.Cos(num5 * Math.PI / 180.0);
double value2 = (double)num3 + (double)num4 * Math.Sin(num5 * Math.PI / 180.0);
double value3 = (double)num2 + (double)num4 * Math.Cos(num8 * Math.PI / 180.0);
double value4 = (double)num3 + (double)num4 * Math.Sin(num8 * Math.PI / 180.0);
int value5 = ((num7 > 180.0) ? 1 : 0);
string value6 = ((i < Palette.Length) ? Palette[i] : Palette[i % Palette.Length]);
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(37, 10, stringBuilder2);
handler.AppendLiteral("<path d=\"M");
handler.AppendFormatted(num2);
handler.AppendLiteral(",");
handler.AppendFormatted(num3);
handler.AppendLiteral(" L");
handler.AppendFormatted(value, "F1");
handler.AppendLiteral(",");
handler.AppendFormatted(value2, "F1");
handler.AppendLiteral(" A");
handler.AppendFormatted(num4);
handler.AppendLiteral(",");
handler.AppendFormatted(num4);
handler.AppendLiteral(" 0 ");
handler.AppendFormatted(value5);
handler.AppendLiteral(",1 ");
handler.AppendFormatted(value3, "F1");
handler.AppendLiteral(",");
handler.AppendFormatted(value4, "F1");
handler.AppendLiteral(" Z\" fill=\"");
handler.AppendFormatted(value6);
handler.AppendLiteral("\"/>");
stringBuilder3.AppendLine(ref handler);
num5 = num8;
}
if (isDonut)
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(39, 3, stringBuilder2);
handler.AppendLiteral("<circle cx=\"");
handler.AppendFormatted(num2);
handler.AppendLiteral("\" cy=\"");
handler.AppendFormatted(num3);
handler.AppendLiteral("\" r=\"");
handler.AppendFormatted((double)num4 * 0.55);
handler.AppendLiteral("\" fill=\"white\"/>");
stringBuilder4.AppendLine(ref handler);
}
stringBuilder.AppendLine("</svg>");
stringBuilder.AppendLine("<div class=\"pie-legend\">");
for (int j = 0; j < Math.Min(list.Count, labels.Count); j++)
{
string value7 = ((j < Palette.Length) ? Palette[j] : Palette[j % Palette.Length]);
double value8 = list[j] / num * 100.0;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(142, 3, stringBuilder2);
handler.AppendLiteral("<div class=\"pie-legend-item\"><span class=\"legend-dot\" style=\"background:");
handler.AppendFormatted(value7);
handler.AppendLiteral("\"></span>");
handler.AppendFormatted(Escape(labels[j]));
handler.AppendLiteral(" <span style=\"color:#6B7280;font-size:12px;\">(");
handler.AppendFormatted(value8, "F1");
handler.AppendLiteral("%)</span></div>");
stringBuilder5.AppendLine(ref handler);
}
stringBuilder.AppendLine("</div></div>");
return stringBuilder.ToString();
}
private static string RenderProgressChart(List<string> labels, List<Dataset> datasets, string unit)
{
List<double> list = ((datasets.Count > 0) ? datasets[0].Values : new List<double>());
StringBuilder stringBuilder = new StringBuilder();
for (int i = 0; i < Math.Min(labels.Count, list.Count); i++)
{
double value = Math.Clamp(list[i], 0.0, 100.0);
string value2 = ((i < Palette.Length) ? Palette[i] : Palette[i % Palette.Length]);
if (datasets.Count > 0 && !string.IsNullOrEmpty(datasets[0].Color))
{
value2 = datasets[0].Color;
}
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(218, 3, stringBuilder2);
handler.AppendLiteral("<div style=\"margin-bottom:12px;\"><div style=\"display:flex;justify-content:space-between;margin-bottom:4px;\"><span style=\"font-size:13px;font-weight:600;\">");
handler.AppendFormatted(Escape(labels[i]));
handler.AppendLiteral("</span><span style=\"font-size:13px;color:#6B7280;\">");
handler.AppendFormatted(list[i], "G");
handler.AppendFormatted(unit);
handler.AppendLiteral("</span></div>");
stringBuilder3.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(96, 2, stringBuilder2);
handler.AppendLiteral("<div class=\"progress\"><div class=\"progress-fill\" style=\"width:");
handler.AppendFormatted(value);
handler.AppendLiteral("%;background:");
handler.AppendFormatted(value2);
handler.AppendLiteral(";\"></div></div></div>");
stringBuilder4.AppendLine(ref handler);
}
return stringBuilder.ToString();
}
private static string RenderComparisonChart(List<string> labels, List<Dataset> datasets, string unit)
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("<table style=\"width:100%;border-collapse:collapse;\">");
stringBuilder.AppendLine("<tr><th style=\"text-align:left;padding:8px 12px;\">항목</th>");
foreach (Dataset dataset in datasets)
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(60, 2, stringBuilder2);
handler.AppendLiteral("<th style=\"text-align:center;padding:8px 12px;color:");
handler.AppendFormatted(dataset.Color);
handler.AppendLiteral(";\">");
handler.AppendFormatted(Escape(dataset.Name));
handler.AppendLiteral("</th>");
stringBuilder3.AppendLine(ref handler);
}
stringBuilder.AppendLine("</tr>");
for (int i = 0; i < labels.Count; i++)
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(93, 1, stringBuilder2);
handler.AppendLiteral("<tr style=\"border-top:1px solid #E5E7EB;\"><td style=\"padding:8px 12px;font-weight:500;\">");
handler.AppendFormatted(Escape(labels[i]));
handler.AppendLiteral("</td>");
stringBuilder4.Append(ref handler);
foreach (Dataset dataset2 in datasets)
{
double value = ((i < dataset2.Values.Count) ? dataset2.Values[i] : 0.0);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(53, 2, stringBuilder2);
handler.AppendLiteral("<td style=\"text-align:center;padding:8px 12px;\">");
handler.AppendFormatted(value, "G");
handler.AppendFormatted(unit);
handler.AppendLiteral("</td>");
stringBuilder5.Append(ref handler);
}
stringBuilder.AppendLine("</tr>");
}
stringBuilder.AppendLine("</table>");
return stringBuilder.ToString();
}
private static string RenderRadarChart(List<string> labels, List<Dataset> datasets)
{
int cx = 150;
int cy = 150;
int r = 110;
int n = labels.Count;
if (n < 3)
{
return "<p>레이더 차트는 최소 3개 항목이 필요합니다.</p>";
}
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("<svg viewBox=\"0 0 300 300\" width=\"300\" height=\"300\">");
for (int i = 1; i <= 4; i++)
{
double lr = (double)(r * i) / 4.0;
string value = string.Join(" ", Enumerable.Range(0, n).Select(delegate(int num5)
{
double num4 = (360.0 / (double)n * (double)num5 - 90.0) * Math.PI / 180.0;
return $"{(double)cx + lr * Math.Cos(num4):F1},{(double)cy + lr * Math.Sin(num4):F1}";
}));
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(66, 1, stringBuilder2);
handler.AppendLiteral("<polygon points=\"");
handler.AppendFormatted(value);
handler.AppendLiteral("\" fill=\"none\" stroke=\"#E5E7EB\" stroke-width=\"1\"/>");
stringBuilder3.AppendLine(ref handler);
}
for (int num = 0; num < n; num++)
{
double num2 = (360.0 / (double)n * (double)num - 90.0) * Math.PI / 180.0;
double value2 = (double)cx + (double)r * Math.Cos(num2);
double value3 = (double)cy + (double)r * Math.Sin(num2);
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(65, 4, stringBuilder2);
handler.AppendLiteral("<line x1=\"");
handler.AppendFormatted(cx);
handler.AppendLiteral("\" y1=\"");
handler.AppendFormatted(cy);
handler.AppendLiteral("\" x2=\"");
handler.AppendFormatted(value2, "F1");
handler.AppendLiteral("\" y2=\"");
handler.AppendFormatted(value3, "F1");
handler.AppendLiteral("\" stroke=\"#D1D5DB\" stroke-width=\"1\"/>");
stringBuilder4.AppendLine(ref handler);
double value4 = (double)cx + (double)(r + 16) * Math.Cos(num2);
double num3 = (double)cy + (double)(r + 16) * Math.Sin(num2);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(74, 3, stringBuilder2);
handler.AppendLiteral("<text x=\"");
handler.AppendFormatted(value4, "F0");
handler.AppendLiteral("\" y=\"");
handler.AppendFormatted(num3 + 4.0, "F0");
handler.AppendLiteral("\" text-anchor=\"middle\" fill=\"#374151\" font-size=\"11\">");
handler.AppendFormatted(Escape(labels[num]));
handler.AppendLiteral("</text>");
stringBuilder5.AppendLine(ref handler);
}
double maxVal = datasets.SelectMany((Dataset d) => d.Values).DefaultIfEmpty(1.0).Max();
if (maxVal <= 0.0)
{
maxVal = 1.0;
}
foreach (Dataset ds in datasets)
{
string value5 = string.Join(" ", Enumerable.Range(0, n).Select(delegate(int num5)
{
double num4 = ((num5 < ds.Values.Count) ? ds.Values[num5] : 0.0);
double num6 = (double)r * num4 / maxVal;
double num7 = (360.0 / (double)n * (double)num5 - 90.0) * Math.PI / 180.0;
return $"{(double)cx + num6 * Math.Cos(num7):F1},{(double)cy + num6 * Math.Sin(num7):F1}";
}));
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(74, 3, stringBuilder2);
handler.AppendLiteral("<polygon points=\"");
handler.AppendFormatted(value5);
handler.AppendLiteral("\" fill=\"");
handler.AppendFormatted(ds.Color);
handler.AppendLiteral("\" fill-opacity=\"0.2\" stroke=\"");
handler.AppendFormatted(ds.Color);
handler.AppendLiteral("\" stroke-width=\"2\"/>");
stringBuilder6.AppendLine(ref handler);
}
stringBuilder.AppendLine("</svg>");
return stringBuilder.ToString();
}
private static List<string> ParseStringArray(JsonElement parent, string prop)
{
if (!parent.TryGetProperty(prop, out var value) || value.ValueKind != JsonValueKind.Array)
{
return new List<string>();
}
return (from e in value.EnumerateArray()
select e.GetString() ?? "").ToList();
}
private List<Dataset> ParseDatasets(JsonElement chart)
{
if (!chart.TryGetProperty("datasets", out var value) || value.ValueKind != JsonValueKind.Array)
{
double value6;
if (chart.TryGetProperty("values", out var value2) && value2.ValueKind == JsonValueKind.Array)
{
return new List<Dataset>
{
new Dataset
{
Name = "Data",
Values = (from v in value2.EnumerateArray()
select v.TryGetDouble(out value6) ? value6 : 0.0).ToList(),
Color = Palette[0]
}
};
}
return new List<Dataset>();
}
List<Dataset> list = new List<Dataset>();
int num = 0;
foreach (JsonElement item in value.EnumerateArray())
{
JsonElement value3;
string name = (item.TryGetProperty("name", out value3) ? (value3.GetString() ?? $"Series{num + 1}") : $"Series{num + 1}");
JsonElement value4;
string color = (item.TryGetProperty("color", out value4) ? (value4.GetString() ?? Palette[num % Palette.Length]) : Palette[num % Palette.Length]);
List<double> values = new List<double>();
if (item.TryGetProperty("values", out var value5) && value5.ValueKind == JsonValueKind.Array)
{
values = (from e in value5.EnumerateArray()
select e.TryGetDouble(out var value6) ? value6 : 0.0).ToList();
}
list.Add(new Dataset
{
Name = name,
Values = values,
Color = color
});
num++;
}
return list;
}
private static string Escape(string s)
{
return s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;")
.Replace("\"", "&quot;");
}
private static string FormatSize(long bytes)
{
if (1 == 0)
{
}
string result = ((bytes < 1024) ? $"{bytes}B" : ((bytes >= 1048576) ? $"{(double)bytes / 1048576.0:F1}MB" : $"{(double)bytes / 1024.0:F1}KB"));
if (1 == 0)
{
}
return result;
}
}

View File

@@ -0,0 +1,433 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public class CheckpointTool : IAgentTool
{
private class CheckpointManifest
{
public string Name { get; set; } = "";
public string CreatedAt { get; set; } = "";
public string WorkFolder { get; set; } = "";
public List<FileEntry> Files { get; set; } = new List<FileEntry>();
}
private class FileEntry
{
public string Path { get; set; } = "";
public string Hash { get; set; } = "";
}
private const int MaxCheckpoints = 10;
private const long MaxFileSize = 5242880L;
private static readonly HashSet<string> SkipDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"bin", "obj", "node_modules", ".git", ".vs", ".ax", "packages", "Debug", "Release", "TestResults",
".idea", "__pycache__"
};
private static readonly HashSet<string> TextExtensions = new HashSet<string>(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 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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "create | list | restore | delete"
};
int num = 4;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "create";
span[1] = "list";
span[2] = "restore";
span[3] = "delete";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["name"] = new ToolProperty
{
Type = "string",
Description = "Checkpoint name (for create/restore)"
};
dictionary["id"] = new ToolProperty
{
Type = "integer",
Description = "Checkpoint ID (for restore/delete)"
};
toolParameterSchema.Properties = dictionary;
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "action";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
if (!args.TryGetProperty("action", out var actionEl))
{
return ToolResult.Fail("action이 필요합니다.");
}
string action = actionEl.GetString() ?? "";
if (string.IsNullOrEmpty(context.WorkFolder))
{
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
}
string checkpointDir = Path.Combine(context.WorkFolder, ".ax", "checkpoints");
if (1 == 0)
{
}
ToolResult result = 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 중 선택하세요."),
};
if (1 == 0)
{
}
return result;
}
private async Task<ToolResult> CreateCheckpoint(JsonElement args, AgentContext context, string checkpointDir, CancellationToken ct)
{
string name = (args.TryGetProperty("name", out var n) ? (n.GetString() ?? "unnamed") : "unnamed");
name = string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
string folderName = timestamp + "_" + name;
string cpDir = Path.Combine(checkpointDir, folderName);
try
{
Directory.CreateDirectory(cpDir);
List<string> files = CollectTextFiles(context.WorkFolder);
if (files.Count == 0)
{
return ToolResult.Fail("체크포인트할 텍스트 파일이 없습니다.");
}
CheckpointManifest manifest = new CheckpointManifest
{
Name = name,
CreatedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
WorkFolder = context.WorkFolder,
Files = new List<FileEntry>()
};
foreach (string file in files)
{
ct.ThrowIfCancellationRequested();
string relativePath = Path.GetRelativePath(context.WorkFolder, file);
string destPath = Path.Combine(cpDir, relativePath);
string destDir = Path.GetDirectoryName(destPath);
if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir))
{
Directory.CreateDirectory(destDir);
}
await CopyFileAsync(file, destPath, ct);
string hash = await ComputeHashAsync(file, ct);
manifest.Files.Add(new FileEntry
{
Path = relativePath,
Hash = hash
});
}
await File.WriteAllTextAsync(contents: JsonSerializer.Serialize(manifest, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
}), path: Path.Combine(cpDir, "manifest.json"), cancellationToken: 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("저장된 체크포인트가 없습니다.");
}
List<string> list = (from d in Directory.GetDirectories(checkpointDir)
orderby d descending
select d).ToList();
if (list.Count == 0)
{
return ToolResult.Ok("저장된 체크포인트가 없습니다.");
}
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(8, 1, stringBuilder2);
handler.AppendLiteral("체크포인트 ");
handler.AppendFormatted(list.Count);
handler.AppendLiteral("개:");
stringBuilder3.AppendLine(ref handler);
for (int num = 0; num < list.Count; num++)
{
string fileName = Path.GetFileName(list[num]);
string path = Path.Combine(list[num], "manifest.json");
int value = 0;
string text = "";
if (File.Exists(path))
{
try
{
string json = File.ReadAllText(path);
CheckpointManifest checkpointManifest = JsonSerializer.Deserialize<CheckpointManifest>(json);
value = checkpointManifest?.Files.Count ?? 0;
text = checkpointManifest?.CreatedAt ?? "";
}
catch
{
}
}
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(12, 4, stringBuilder2);
handler.AppendLiteral(" [");
handler.AppendFormatted(num);
handler.AppendLiteral("] ");
handler.AppendFormatted(fileName);
handler.AppendLiteral(" — 파일 ");
handler.AppendFormatted(value);
handler.AppendLiteral("개");
handler.AppendFormatted(string.IsNullOrEmpty(text) ? "" : (", " + text));
stringBuilder4.AppendLine(ref handler);
}
return ToolResult.Ok(stringBuilder.ToString());
}
private async Task<ToolResult> RestoreCheckpoint(JsonElement args, AgentContext context, string checkpointDir, CancellationToken ct)
{
if (!Directory.Exists(checkpointDir))
{
return ToolResult.Fail("저장된 체크포인트가 없습니다.");
}
List<string> dirs = (from d in Directory.GetDirectories(checkpointDir)
orderby d descending
select d).ToList();
if (dirs.Count == 0)
{
return ToolResult.Fail("저장된 체크포인트가 없습니다.");
}
string targetDir = null;
if (args.TryGetProperty("id", out var idEl))
{
int parsed;
int id = ((idEl.ValueKind == JsonValueKind.Number) ? idEl.GetInt32() : (int.TryParse(idEl.GetString(), out parsed) ? parsed : (-1)));
if (id >= 0 && id < dirs.Count)
{
targetDir = dirs[id];
}
}
if (targetDir == null && args.TryGetProperty("name", out var nameEl))
{
string name = nameEl.GetString() ?? "";
targetDir = dirs.FirstOrDefault((string d) => Path.GetFileName(d).Contains(name, StringComparison.OrdinalIgnoreCase));
}
if (targetDir == null)
{
return ToolResult.Fail("체크포인트를 찾을 수 없습니다. id 또는 name을 확인하세요.");
}
if (context.AskPermission != null && !(await context.AskPermission("checkpoint_restore", "체크포인트 복원: " + Path.GetFileName(targetDir))))
{
return ToolResult.Ok("사용자가 복원을 거부했습니다.");
}
try
{
string manifestPath = Path.Combine(targetDir, "manifest.json");
if (!File.Exists(manifestPath))
{
return ToolResult.Fail("체크포인트 매니페스트를 찾을 수 없습니다.");
}
CheckpointManifest manifest = JsonSerializer.Deserialize<CheckpointManifest>(await File.ReadAllTextAsync(manifestPath, ct));
if (manifest == null)
{
return ToolResult.Fail("매니페스트 파싱 오류");
}
int restoredCount = 0;
foreach (FileEntry entry in manifest.Files)
{
ct.ThrowIfCancellationRequested();
string srcPath = Path.Combine(targetDir, entry.Path);
string destPath = Path.Combine(context.WorkFolder, entry.Path);
if (File.Exists(srcPath))
{
string 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("저장된 체크포인트가 없습니다.");
}
List<string> list = (from d in Directory.GetDirectories(checkpointDir)
orderby d descending
select d).ToList();
if (!args.TryGetProperty("id", out var value))
{
return ToolResult.Fail("삭제할 체크포인트 id가 필요합니다.");
}
int result;
int num = ((value.ValueKind == JsonValueKind.Number) ? value.GetInt32() : (int.TryParse(value.GetString(), out result) ? result : (-1)));
if (num < 0 || num >= list.Count)
{
return ToolResult.Fail($"잘못된 체크포인트 ID: {num}. 0~{list.Count - 1} 범위를 사용하세요.");
}
try
{
string path = list[num];
string fileName = Path.GetFileName(path);
Directory.Delete(path, recursive: true);
return ToolResult.Ok("체크포인트 삭제됨: " + fileName);
}
catch (Exception ex)
{
return ToolResult.Fail("체크포인트 삭제 오류: " + ex.Message);
}
}
private List<string> CollectTextFiles(string workFolder)
{
List<string> list = new List<string>();
CollectFilesRecursive(workFolder, workFolder, list);
return list;
}
private void CollectFilesRecursive(string dir, string rootDir, List<string> files)
{
string fileName = Path.GetFileName(dir);
if (dir != rootDir && SkipDirs.Contains(fileName))
{
return;
}
try
{
string[] files2 = Directory.GetFiles(dir);
foreach (string text in files2)
{
string extension = Path.GetExtension(text);
if (TextExtensions.Contains(extension))
{
FileInfo fileInfo = new FileInfo(text);
if (fileInfo.Length <= 5242880)
{
files.Add(text);
}
}
}
string[] directories = Directory.GetDirectories(dir);
foreach (string dir2 in directories)
{
CollectFilesRecursive(dir2, rootDir, files);
}
}
catch (UnauthorizedAccessException)
{
}
}
private static async Task CopyFileAsync(string src, string dest, CancellationToken ct)
{
byte[] buffer = new byte[81920];
await using FileStream srcStream = new FileStream(src, FileMode.Open, FileAccess.Read, FileShare.Read, buffer.Length, useAsync: true);
await using FileStream destStream = new FileStream(dest, FileMode.Create, FileAccess.Write, FileShare.None, buffer.Length, useAsync: true);
while (true)
{
int num;
int bytesRead = (num = await srcStream.ReadAsync(buffer, ct));
if (num <= 0)
{
break;
}
await destStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct);
}
}
private static async Task<string> ComputeHashAsync(string filePath, CancellationToken ct)
{
using SHA256 sha256 = SHA256.Create();
string result;
await using (FileStream stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true))
{
result = Convert.ToHexString(await sha256.ComputeHashAsync(stream, ct)).ToLowerInvariant();
}
return result;
}
private static void CleanupOldCheckpoints(string checkpointDir)
{
if (!Directory.Exists(checkpointDir))
{
return;
}
List<string> list = (from d in Directory.GetDirectories(checkpointDir)
orderby d
select d).ToList();
while (list.Count > 10)
{
try
{
Directory.Delete(list[0], recursive: true);
list.RemoveAt(0);
}
catch
{
break;
}
}
}
}

View File

@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Action to perform"
};
int num = 4;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "read";
span[1] = "write";
span[2] = "has_text";
span[3] = "has_image";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["text"] = new ToolProperty
{
Type = "string",
Description = "Text to write to clipboard (required for 'write' action)"
};
toolParameterSchema.Properties = dictionary;
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "action";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string action = args.GetProperty("action").GetString() ?? "";
try
{
ToolResult result = null;
((DispatcherObject)Application.Current).Dispatcher.Invoke((Action)delegate
{
if (1 == 0)
{
}
ToolResult toolResult = 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),
};
if (1 == 0)
{
}
result = toolResult;
});
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)");
}
string text = Clipboard.GetText();
if (string.IsNullOrEmpty(text))
{
return ToolResult.Ok("(empty)");
}
if (text.Length > 10000)
{
return ToolResult.Ok(text.Substring(0, 10000) + $"\n\n... (truncated, total {text.Length} chars)");
}
return ToolResult.Ok(text);
}
private static ToolResult WriteClipboard(JsonElement args)
{
if (!args.TryGetProperty("text", out var value))
{
return ToolResult.Fail("'text' parameter is required for write action");
}
string text = value.GetString() ?? "";
Clipboard.SetText(text);
return ToolResult.Ok($"✓ Clipboard updated ({text.Length} chars)");
}
}

View File

@@ -0,0 +1,575 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
namespace AxCopilot.Services.Agent;
public class CodeReviewTool : IAgentTool
{
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 List<string>();
}
private record ReviewIssue(int Line, string Severity, string Message);
public string Name => "code_review";
public string Description => "코드 리뷰를 수행합니다. Git diff 분석, 파일 정적 검사, PR 요약을 생성합니다.\naction별 기능:\n- diff_review: git diff 출력을 분석하여 이슈/개선점을 구조화\n- file_review: 특정 파일의 코드 품질을 정적 검사\n- pr_summary: 변경사항을 PR 설명 형식으로 요약";
public ToolParameterSchema Parameters
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "리뷰 유형: diff_review (diff 분석), file_review (파일 검사), pr_summary (PR 요약)"
};
int num = 3;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "diff_review";
span[1] = "file_review";
span[2] = "pr_summary";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["target"] = new ToolProperty
{
Type = "string",
Description = "대상 지정. diff_review: '--staged' 또는 빈값(working tree). file_review: 파일 경로. pr_summary: 브랜치명(선택)."
};
ToolProperty obj2 = new ToolProperty
{
Type = "string",
Description = "리뷰 초점: all (전체), bugs (버그), performance (성능), security (보안), style (스타일). 기본 all."
};
num = 5;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
Span<string> span2 = CollectionsMarshal.AsSpan(list2);
span2[0] = "all";
span2[1] = "bugs";
span2[2] = "performance";
span2[3] = "security";
span2[4] = "style";
obj2.Enum = list2;
dictionary["focus"] = obj2;
toolParameterSchema.Properties = dictionary;
num = 1;
List<string> list3 = new List<string>(num);
CollectionsMarshal.SetCount(list3, num);
CollectionsMarshal.AsSpan(list3)[0] = "action";
toolParameterSchema.Required = list3;
return toolParameterSchema;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
if (!((Application.Current as App)?.SettingsService?.Settings.Llm.Code.EnableCodeReview ?? true))
{
return ToolResult.Ok("코드 리뷰가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
}
JsonElement a;
string action = (args.TryGetProperty("action", out a) ? (a.GetString() ?? "") : "");
JsonElement t;
string target = (args.TryGetProperty("target", out t) ? (t.GetString() ?? "") : "");
JsonElement f;
string focus = (args.TryGetProperty("focus", out f) ? (f.GetString() ?? "all") : "all");
if (string.IsNullOrEmpty(context.WorkFolder))
{
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
}
if (1 == 0)
{
}
ToolResult result = 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 중 선택하세요."),
};
if (1 == 0)
{
}
return result;
}
private async Task<ToolResult> DiffReviewAsync(AgentContext ctx, string target, string focus, CancellationToken ct)
{
string diffResult = await RunGitAsync(args: string.IsNullOrEmpty(target) ? "diff" : ("diff " + target), workDir: ctx.WorkFolder, ct: ct);
if (diffResult == null)
{
return ToolResult.Fail("Git을 찾을 수 없습니다.");
}
if (string.IsNullOrWhiteSpace(diffResult))
{
return ToolResult.Ok("변경사항이 없습니다. (clean working tree)");
}
List<DiffFile> files = ParseDiffFiles(diffResult);
StringBuilder sb = new StringBuilder();
sb.AppendLine("═══ Code Review Report (diff_review) ═══\n");
int totalAdded = 0;
int totalRemoved = 0;
foreach (DiffFile file in files)
{
totalAdded += file.Added;
totalRemoved += file.Removed;
}
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(26, 3, stringBuilder);
handler.AppendLiteral("\ud83d\udcca 파일 ");
handler.AppendFormatted(files.Count);
handler.AppendLiteral("개 변경 | +");
handler.AppendFormatted(totalAdded);
handler.AppendLiteral(" 추가 -");
handler.AppendFormatted(totalRemoved);
handler.AppendLiteral(" 삭제\n");
stringBuilder2.AppendLine(ref handler);
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder);
handler.AppendLiteral("\ud83d\udd0d 리뷰 초점: ");
handler.AppendFormatted(focus);
handler.AppendLiteral("\n");
stringBuilder3.AppendLine(ref handler);
foreach (DiffFile file2 in files)
{
stringBuilder = sb;
StringBuilder stringBuilder4 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(11, 2, stringBuilder);
handler.AppendLiteral("─── ");
handler.AppendFormatted(file2.Path);
handler.AppendLiteral(" (");
handler.AppendFormatted(file2.Status);
handler.AppendLiteral(") ───");
stringBuilder4.AppendLine(ref handler);
stringBuilder = sb;
StringBuilder stringBuilder5 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 2, stringBuilder);
handler.AppendLiteral(" 변경: +");
handler.AppendFormatted(file2.Added);
handler.AppendLiteral(" -");
handler.AppendFormatted(file2.Removed);
stringBuilder5.AppendLine(ref handler);
List<ReviewIssue> issues = AnalyzeDiffHunks(file2.Hunks, focus);
if (issues.Count > 0)
{
foreach (ReviewIssue issue in issues)
{
stringBuilder = sb;
StringBuilder stringBuilder6 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(12, 3, stringBuilder);
handler.AppendLiteral(" [");
handler.AppendFormatted(issue.Severity);
handler.AppendLiteral("] Line ");
handler.AppendFormatted(issue.Line);
handler.AppendLiteral(": ");
handler.AppendFormatted(issue.Message);
stringBuilder6.AppendLine(ref handler);
}
}
else
{
sb.AppendLine(" [OK] 정적 검사에서 특이사항 없음");
}
sb.AppendLine();
}
sb.AppendLine("═══ 위 분석 결과를 바탕으로 상세한 코드 리뷰를 작성하세요. ═══");
return ToolResult.Ok(sb.ToString());
}
private async Task<ToolResult> FileReviewAsync(AgentContext ctx, string target, string focus, CancellationToken ct)
{
if (string.IsNullOrEmpty(target))
{
return ToolResult.Fail("file_review에는 target(파일 경로)이 필요합니다.");
}
string 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);
}
string[] lines = (await File.ReadAllTextAsync(fullPath, ct)).Split('\n');
StringBuilder sb = new StringBuilder();
sb.AppendLine("═══ Code Review Report (file_review) ═══\n");
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder);
handler.AppendLiteral("\ud83d\udcc1 파일: ");
handler.AppendFormatted(target);
stringBuilder2.AppendLine(ref handler);
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(17, 2, stringBuilder);
handler.AppendLiteral("\ud83d\udccf ");
handler.AppendFormatted(lines.Length);
handler.AppendLiteral("줄 | \ud83d\udd0d 초점: ");
handler.AppendFormatted(focus);
handler.AppendLiteral("\n");
stringBuilder3.AppendLine(ref handler);
List<ReviewIssue> issues = AnalyzeFile(lines, focus);
if (issues.Count > 0)
{
stringBuilder = sb;
StringBuilder stringBuilder4 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(13, 1, stringBuilder);
handler.AppendLiteral("⚠\ufe0f 발견된 이슈 ");
handler.AppendFormatted(issues.Count);
handler.AppendLiteral("개:\n");
stringBuilder4.AppendLine(ref handler);
foreach (ReviewIssue issue in issues)
{
stringBuilder = sb;
StringBuilder stringBuilder5 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(12, 3, stringBuilder);
handler.AppendLiteral(" [");
handler.AppendFormatted(issue.Severity);
handler.AppendLiteral("] Line ");
handler.AppendFormatted(issue.Line);
handler.AppendLiteral(": ");
handler.AppendFormatted(issue.Message);
stringBuilder5.AppendLine(ref handler);
}
}
else
{
sb.AppendLine("✅ 정적 검사에서 특이사항 없음");
}
sb.AppendLine("\n─── 파일 내용 (처음 200줄) ───");
string preview = string.Join('\n', lines.Take(200));
sb.AppendLine(preview);
if (lines.Length > 200)
{
stringBuilder = sb;
StringBuilder stringBuilder6 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder);
handler.AppendLiteral("\n... (");
handler.AppendFormatted(lines.Length - 200);
handler.AppendLiteral("줄 생략)");
stringBuilder6.AppendLine(ref handler);
}
sb.AppendLine("\n═══ 위 분석 결과와 코드를 바탕으로 상세한 리뷰를 작성하세요. ═══");
return ToolResult.Ok(sb.ToString());
}
private async Task<ToolResult> PrSummaryAsync(AgentContext ctx, string target, CancellationToken ct)
{
StringBuilder sb = new StringBuilder();
sb.AppendLine("═══ PR Summary Data ═══\n");
string log = await RunGitAsync(args: string.IsNullOrEmpty(target) ? "log --oneline -20" : ("log --oneline " + target + "..HEAD"), workDir: ctx.WorkFolder, ct: ct);
if (!string.IsNullOrWhiteSpace(log))
{
sb.AppendLine("\ud83d\udccb 커밋 이력:");
sb.AppendLine(log);
sb.AppendLine();
}
string stat = await RunGitAsync(args: string.IsNullOrEmpty(target) ? "diff --stat" : ("diff --stat " + target + "..HEAD"), workDir: ctx.WorkFolder, ct: ct);
if (!string.IsNullOrWhiteSpace(stat))
{
sb.AppendLine("\ud83d\udcca 변경 통계:");
sb.AppendLine(stat);
sb.AppendLine();
}
string status = await RunGitAsync(ctx.WorkFolder, "status --short", ct);
if (!string.IsNullOrWhiteSpace(status))
{
sb.AppendLine("\ud83d\udcc1 현재 상태:");
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)
{
List<ReviewIssue> list = new List<ReviewIssue>();
int num = 0;
foreach (string hunk in hunks)
{
Match match = Regex.Match(hunk, "@@ -\\d+(?:,\\d+)? \\+(\\d+)");
if (match.Success)
{
num = int.Parse(match.Groups[1].Value);
}
else
{
if (!hunk.StartsWith('+') || hunk.StartsWith("+++"))
{
continue;
}
num++;
string text = hunk;
string text2 = text.Substring(1, text.Length - 1);
if ((focus == "all" || focus == "bugs") ? true : false)
{
if (Regex.IsMatch(text2, "catch\\s*\\{?\\s*\\}"))
{
list.Add(new ReviewIssue(num, "WARNING", "빈 catch 블록 — 예외가 무시됩니다"));
}
if (Regex.IsMatch(text2, "\\.Result\\b|\\.Wait\\(\\)"))
{
list.Add(new ReviewIssue(num, "WARNING", "동기 대기 (.Result/.Wait()) — 데드락 위험"));
}
if (Regex.IsMatch(text2, "==\\s*null") && text2.Contains('.'))
{
list.Add(new ReviewIssue(num, "INFO", "null 비교 — null 조건 연산자(?.) 사용 검토"));
}
}
if ((focus == "all" || focus == "security") ? true : false)
{
if (Regex.IsMatch(text2, "(password|secret|token|api_?key)\\s*=\\s*\"[^\"]+\"", RegexOptions.IgnoreCase))
{
list.Add(new ReviewIssue(num, "CRITICAL", "하드코딩된 비밀번호/키 감지"));
}
if (Regex.IsMatch(text2, "(TODO|FIXME|HACK|XXX)\\b", RegexOptions.IgnoreCase))
{
list.Add(new ReviewIssue(num, "INFO", "TODO/FIXME 마커 발견: " + text2.Trim()));
}
}
if ((focus == "all" || focus == "performance") ? true : false)
{
if (Regex.IsMatch(text2, "new\\s+List<.*>\\(\\).*\\.Add\\(") || Regex.IsMatch(text2, "\\.ToList\\(\\).*\\.Where\\("))
{
list.Add(new ReviewIssue(num, "INFO", "불필요한 컬렉션 할당 가능성"));
}
if (Regex.IsMatch(text2, "string\\s*\\+\\s*=|\".*\"\\s*\\+\\s*\""))
{
list.Add(new ReviewIssue(num, "INFO", "문자열 연결 — StringBuilder 사용 검토"));
}
}
bool flag = ((focus == "all" || focus == "style") ? true : false);
if (flag && text2.Length > 150)
{
list.Add(new ReviewIssue(num, "STYLE", $"긴 라인 ({text2.Length}자) — 가독성 저하"));
}
}
}
return list;
}
private static List<ReviewIssue> AnalyzeFile(string[] lines, string focus)
{
List<ReviewIssue> list = new List<ReviewIssue>();
int num = 0;
int num2 = 0;
bool flag = false;
for (int i = 0; i < lines.Length; i++)
{
string text = lines[i];
int num3 = i + 1;
string text2 = text.TrimStart();
if (Regex.IsMatch(text2, "(public|private|protected|internal|static|async|override)\\s+.*\\(.*\\)\\s*\\{?\\s*$") && !text2.Contains(';'))
{
flag = true;
num2 = num3;
}
if (text2.Contains('{'))
{
num++;
}
bool flag4;
if (text2.Contains('}'))
{
num--;
if (flag && num <= 1)
{
int num4 = num3 - num2;
bool flag2 = num4 > 60;
bool flag3 = flag2;
if (flag3)
{
flag4 = ((focus == "all" || focus == "style") ? true : false);
flag3 = flag4;
}
if (flag3)
{
list.Add(new ReviewIssue(num2, "STYLE", $"긴 메서드 ({num4}줄) — 분할 검토"));
}
flag = false;
}
}
if ((focus == "all" || focus == "bugs") ? true : false)
{
if (Regex.IsMatch(text2, "catch\\s*(\\(\\s*Exception)?\\s*\\)?\\s*\\{\\s*\\}"))
{
list.Add(new ReviewIssue(num3, "WARNING", "빈 catch 블록"));
}
if (Regex.IsMatch(text2, "\\.Result\\b|\\.Wait\\(\\)"))
{
list.Add(new ReviewIssue(num3, "WARNING", "동기 대기 (.Result/.Wait()) — 데드락 위험"));
}
}
if ((focus == "all" || focus == "security") ? true : false)
{
if (Regex.IsMatch(text2, "(password|secret|token|api_?key)\\s*=\\s*\"[^\"]+\"", RegexOptions.IgnoreCase))
{
list.Add(new ReviewIssue(num3, "CRITICAL", "하드코딩된 비밀번호/키"));
}
if (Regex.IsMatch(text2, "(TODO|FIXME|HACK|XXX)\\b", RegexOptions.IgnoreCase))
{
list.Add(new ReviewIssue(num3, "INFO", "마커: " + text2.Trim()));
}
}
flag4 = ((focus == "all" || focus == "performance") ? true : false);
if (flag4 && Regex.IsMatch(text2, "string\\s*\\+\\s*="))
{
list.Add(new ReviewIssue(num3, "INFO", "루프 내 문자열 연결 — StringBuilder 검토"));
}
flag4 = ((focus == "all" || focus == "style") ? true : false);
if (flag4 && text.Length > 150)
{
list.Add(new ReviewIssue(num3, "STYLE", $"긴 라인 ({text.Length}자)"));
}
}
bool flag5 = lines.Length > 500;
bool flag6 = flag5;
if (flag6)
{
bool flag4 = ((focus == "all" || focus == "style") ? true : false);
flag6 = flag4;
}
if (flag6)
{
list.Add(new ReviewIssue(1, "STYLE", $"큰 파일 ({lines.Length}줄) — 클래스 분할 검토"));
}
return list;
}
private static async Task<string?> RunGitAsync(string workDir, string args, CancellationToken ct)
{
string gitPath = FindGit();
if (gitPath == null)
{
return null;
}
try
{
ProcessStartInfo psi = new ProcessStartInfo(gitPath, args)
{
WorkingDirectory = workDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(30.0));
using Process proc = Process.Start(psi);
if (proc == null)
{
return null;
}
string stdout = await proc.StandardOutput.ReadToEndAsync(cts.Token);
await proc.WaitForExitAsync(cts.Token);
return (stdout.Length > 12000) ? (stdout.Substring(0, 12000) + "\n... (출력 잘림)") : stdout;
}
catch
{
return null;
}
}
private static string? FindGit()
{
string[] array = new string[3] { "git", "C:\\Program Files\\Git\\bin\\git.exe", "C:\\Program Files (x86)\\Git\\bin\\git.exe" };
string[] array2 = array;
foreach (string text in array2)
{
try
{
ProcessStartInfo startInfo = new ProcessStartInfo(text, "--version")
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
process?.WaitForExit(3000);
if (process != null && process.ExitCode == 0)
{
return text;
}
}
catch
{
}
}
return null;
}
private static List<DiffFile> ParseDiffFiles(string diff)
{
List<DiffFile> list = new List<DiffFile>();
DiffFile diffFile = null;
string[] array = diff.Split('\n');
foreach (string text in array)
{
if (text.StartsWith("diff --git"))
{
diffFile = new DiffFile();
list.Add(diffFile);
string[] array2 = text.Split(" b/");
diffFile.Path = ((array2.Length > 1) ? array2[1].Trim() : text);
}
else
{
if (diffFile == null)
{
continue;
}
if (text.StartsWith("new file"))
{
diffFile.Status = "added";
}
else if (text.StartsWith("deleted file"))
{
diffFile.Status = "deleted";
}
else if (text.StartsWith("@@") || text.StartsWith("+") || text.StartsWith("-"))
{
diffFile.Hunks.Add(text);
if (text.StartsWith("+") && !text.StartsWith("+++"))
{
diffFile.Added++;
}
if (text.StartsWith("-") && !text.StartsWith("---"))
{
diffFile.Removed++;
}
}
}
}
return list;
}
}

View File

@@ -0,0 +1,127 @@
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
namespace AxCopilot.Services.Agent;
public class CodeSearchTool : IAgentTool
{
private static CodeIndexService? _indexService;
private static string _lastWorkFolder = "";
public string Name => "search_codebase";
public string Description => "코드베이스를 시맨틱 검색합니다. 자연어 질문으로 관련 있는 코드를 찾습니다.\n예: '사용자 인증 로직', '데이터베이스 연결 설정', '에러 핸들링 패턴'\n인덱스는 영속 저장되어 변경된 파일만 증분 업데이트합니다.";
public ToolParameterSchema Parameters => new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["query"] = new ToolProperty
{
Type = "string",
Description = "검색할 내용 (자연어 또는 키워드)"
},
["max_results"] = new ToolProperty
{
Type = "integer",
Description = "최대 결과 수 (기본 5)"
},
["reindex"] = new ToolProperty
{
Type = "boolean",
Description = "true면 기존 인덱스를 버리고 전체 재인덱싱 (기본 false)"
}
},
Required = new List<string> { "query" }
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
if (!((Application.Current as App)?.SettingsService?.Settings.Llm.Code.EnableCodeIndex ?? true))
{
return ToolResult.Ok("코드 시맨틱 검색이 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
}
JsonElement q;
string query = (args.TryGetProperty("query", out q) ? (q.GetString() ?? "") : "");
JsonElement m;
int maxResults = (args.TryGetProperty("max_results", out m) ? m.GetInt32() : 5);
JsonElement ri;
bool reindex = args.TryGetProperty("reindex", out 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;
if (!reindex)
{
_indexService.TryLoadExisting(context.WorkFolder);
}
}
if (!_indexService.IsIndexed || reindex)
{
await _indexService.IndexAsync(context.WorkFolder, ct);
if (!_indexService.IsIndexed)
{
return ToolResult.Fail("코드 인덱싱에 실패했습니다.");
}
}
List<SearchResult> results = _indexService.Search(query, maxResults);
if (results.Count == 0)
{
return ToolResult.Ok("'" + query + "'에 대한 관련 코드를 찾지 못했습니다. 다른 키워드로 검색해보세요.");
}
StringBuilder sb = new StringBuilder();
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(24, 3, stringBuilder);
handler.AppendLiteral("'");
handler.AppendFormatted(query);
handler.AppendLiteral("' 검색 결과 (");
handler.AppendFormatted(results.Count);
handler.AppendLiteral("개, 인덱스 ");
handler.AppendFormatted(_indexService.ChunkCount);
handler.AppendLiteral("개 청크):\n");
stringBuilder2.AppendLine(ref handler);
foreach (SearchResult r in results)
{
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(20, 4, stringBuilder);
handler.AppendLiteral("\ud83d\udcc1 ");
handler.AppendFormatted(r.FilePath);
handler.AppendLiteral(" (line ");
handler.AppendFormatted(r.StartLine);
handler.AppendLiteral("-");
handler.AppendFormatted(r.EndLine);
handler.AppendLiteral(", score ");
handler.AppendFormatted(r.Score, "F3");
handler.AppendLiteral(")");
stringBuilder3.AppendLine(ref handler);
sb.AppendLine("```");
sb.AppendLine(r.Preview);
sb.AppendLine("```");
sb.AppendLine();
}
return ToolResult.Ok(sb.ToString());
}
public static void InvalidateIndex()
{
_indexService?.Dispose();
_indexService = null;
_lastWorkFolder = "";
}
}

View File

@@ -0,0 +1,246 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public static class ContextCondenser
{
private const int MaxToolResultChars = 1500;
private const int RecentKeepCount = 6;
private static int GetModelInputLimit(string service, string model)
{
string text = (service + ":" + model).ToLowerInvariant();
string text2 = text;
if (1 == 0)
{
}
int result = ((!text.Contains("claude")) ? ((!text.Contains("gemini-2.5")) ? ((!text.Contains("gemini-2.0")) ? ((!text.Contains("gemini")) ? ((!text.Contains("gpt-4")) ? 16000 : 120000) : 900000) : 900000) : 900000) : 180000);
if (1 == 0)
{
}
return result;
}
public static async Task<bool> CondenseIfNeededAsync(List<ChatMessage> messages, LlmService llm, int maxOutputTokens, CancellationToken ct = default(CancellationToken))
{
if (messages.Count < 6)
{
return false;
}
(string service, string model) settings = llm.GetCurrentModelInfo();
int inputLimit = GetModelInputLimit(settings.service, settings.model);
int threshold = (int)((double)inputLimit * 0.65);
int currentTokens = TokenEstimator.EstimateMessages(messages);
if (currentTokens < threshold)
{
return false;
}
bool didCompress = false;
didCompress |= TruncateToolResults(messages);
currentTokens = TokenEstimator.EstimateMessages(messages);
if (currentTokens < threshold)
{
return didCompress;
}
bool flag = didCompress;
didCompress = flag | await SummarizeOldMessagesAsync(messages, llm, ct);
if (didCompress)
{
int afterTokens = TokenEstimator.EstimateMessages(messages);
LogService.Info($"Context Condenser: {currentTokens} → {afterTokens} 토큰 (절감 {currentTokens - afterTokens})");
}
return didCompress;
}
private static bool TruncateToolResults(List<ChatMessage> messages)
{
bool result = false;
int num = Math.Max(0, messages.Count - 6);
for (int i = 0; i < num; i++)
{
ChatMessage chatMessage = messages[i];
if (chatMessage.Content == null)
{
continue;
}
if (chatMessage.Content.StartsWith("{\"type\":\"tool_result\"") && chatMessage.Content.Length > 1500)
{
messages[i] = new ChatMessage
{
Role = chatMessage.Role,
Content = TruncateToolResultJson(chatMessage.Content),
Timestamp = chatMessage.Timestamp
};
result = true;
}
else if (chatMessage.Role == "assistant" && chatMessage.Content.Length > 3000 && chatMessage.Content.StartsWith("{\"_tool_use_blocks\""))
{
if (chatMessage.Content.Length > 4500)
{
messages[i] = new ChatMessage
{
Role = chatMessage.Role,
Content = chatMessage.Content.Substring(0, 3000) + "...[축약됨]\"]}",
Timestamp = chatMessage.Timestamp
};
result = true;
}
}
else if (chatMessage.Content.Length > 4500 && chatMessage.Role != "system")
{
messages[i] = new ChatMessage
{
Role = chatMessage.Role,
Content = chatMessage.Content.Substring(0, 1500) + "\n\n...[이전 내용 축약됨 — 원본 " + $"{chatMessage.Content.Length:N0}자 중 {1500:N0}자 유지]",
Timestamp = chatMessage.Timestamp
};
result = true;
}
}
return result;
}
private static string TruncateToolResultJson(string json)
{
int num = json.IndexOf("\"output\":\"", StringComparison.Ordinal);
if (num < 0)
{
return json.Substring(0, Math.Min(json.Length, 1500)) + "...[축약됨]}";
}
int num2 = num + "\"output\":\"".Length;
int num3 = num2;
while (num3 < json.Length)
{
if (json[num3] == '\\')
{
num3 += 2;
continue;
}
if (json[num3] == '"')
{
break;
}
num3++;
}
int num4 = num3 - num2;
if (num4 <= 1500)
{
return json;
}
int num5 = 750;
string text = json.Substring(0, num2);
int num6 = num2;
string text2 = json.Substring(num6, num3 - num6);
num6 = num3;
string text3 = json.Substring(num6, json.Length - num6);
string[] obj = new string[9]
{
text,
text2.Substring(0, num5),
"\\n...[축약됨: ",
$"{num4:N0}",
"자 중 ",
$"{1500:N0}",
"자 유지]\\n",
null,
null
};
num6 = num5;
int length = text2.Length;
int num7 = length - num6;
obj[7] = text2.Substring(num7, length - num7);
obj[8] = text3;
return string.Concat(obj);
}
private static async Task<bool> SummarizeOldMessagesAsync(List<ChatMessage> messages, LlmService llm, CancellationToken ct)
{
ChatMessage systemMsg = messages.FirstOrDefault((ChatMessage chatMessage) => chatMessage.Role == "system");
if (systemMsg == null)
{
}
List<ChatMessage> nonSystemMessages = messages.Where((ChatMessage chatMessage) => chatMessage.Role != "system").ToList();
int keepCount = Math.Min(6, nonSystemMessages.Count);
List<ChatMessage> recentMessages = nonSystemMessages.Skip(nonSystemMessages.Count - keepCount).ToList();
List<ChatMessage> oldMessages = nonSystemMessages.Take(nonSystemMessages.Count - keepCount).ToList();
if (oldMessages.Count < 3)
{
return false;
}
StringBuilder sb = new StringBuilder();
sb.AppendLine("다음 대화 기록을 간결하게 요약하세요.");
sb.AppendLine("반드시 유지할 정보: 사용자 요청, 핵심 결정 사항, 생성/수정된 파일 경로, 작업 진행 상황, 중요한 결과.");
sb.AppendLine("제거할 정보: 도구 실행의 상세 출력, 반복되는 내용, 중간 사고 과정.");
sb.AppendLine("요약은 대화와 동일한 언어로 작성하세요. 글머리 기호(-)로 핵심 사항만 나열하세요.\n---");
foreach (ChatMessage m in oldMessages)
{
string 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.Substring(0, 300) + "...";
}
StringBuilder stringBuilder = sb;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(4, 2, stringBuilder);
handler.AppendLiteral("[");
handler.AppendFormatted(m.Role);
handler.AppendLiteral("]: ");
handler.AppendFormatted(content);
stringBuilder.AppendLine(ref handler);
}
try
{
List<ChatMessage> summaryMessages = new List<ChatMessage>
{
new ChatMessage
{
Role = "user",
Content = sb.ToString()
}
};
string summary = await llm.SendAsync(summaryMessages, ct);
if (string.IsNullOrEmpty(summary))
{
return false;
}
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,147 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "Output file path (.csv). Relative to work folder."
},
["headers"] = new ToolProperty
{
Type = "array",
Description = "Column headers as JSON array of strings.",
Items = new ToolProperty
{
Type = "string"
}
},
["rows"] = new ToolProperty
{
Type = "array",
Description = "Data rows as JSON array of arrays.",
Items = new ToolProperty
{
Type = "array",
Items = new ToolProperty
{
Type = "string"
}
}
},
["encoding"] = new ToolProperty
{
Type = "string",
Description = "File encoding: 'utf-8' (default) or 'euc-kr'."
}
}
};
int num = 3;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "path";
span[1] = "headers";
span[2] = "rows";
obj.Required = list;
return obj;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string path = args.GetProperty("path").GetString() ?? "";
JsonElement enc;
string encodingName = (args.TryGetProperty("encoding", out enc) ? (enc.GetString() ?? "utf-8") : "utf-8");
string 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
{
JsonElement headers = args.GetProperty("headers");
JsonElement rows = args.GetProperty("rows");
string dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
Encoding fileEncoding;
try
{
fileEncoding = Encoding.GetEncoding(encodingName);
}
catch
{
fileEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
}
StringBuilder sb = new StringBuilder();
List<string> headerValues = new List<string>();
foreach (JsonElement item in headers.EnumerateArray())
{
headerValues.Add(EscapeCsvField(item.GetString() ?? ""));
}
sb.AppendLine(string.Join(",", headerValues));
int rowCount = 0;
foreach (JsonElement row in rows.EnumerateArray())
{
List<string> fields = new List<string>();
foreach (JsonElement item2 in row.EnumerateArray())
{
fields.Add(EscapeCsvField(item2.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,431 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> obj = new Dictionary<string, ToolProperty>
{
["source_path"] = new ToolProperty
{
Type = "string",
Description = "Path to CSV or JSON data file."
},
["group_by"] = new ToolProperty
{
Type = "array",
Description = "Column names to group by.",
Items = new ToolProperty
{
Type = "string"
}
},
["aggregates"] = new ToolProperty
{
Type = "array",
Description = "Aggregation specs: [{\"column\": \"sales\", \"function\": \"sum\"}, ...]. Functions: sum, avg, count, min, max.",
Items = new ToolProperty
{
Type = "object"
}
},
["filter"] = new ToolProperty
{
Type = "string",
Description = "Optional filter expression: 'column == value' or 'column > 100'. Supports: ==, !=, >, <, >=, <=, contains. Multiple conditions: 'region == Seoul AND year >= 2025'."
},
["sort_by"] = new ToolProperty
{
Type = "string",
Description = "Column name to sort results by. Prefix with '-' for descending."
},
["top_n"] = new ToolProperty
{
Type = "integer",
Description = "Limit results to top N rows. Default: all rows."
}
};
ToolProperty obj2 = new ToolProperty
{
Type = "string",
Description = "Output format: table (markdown), csv, json. Default: table"
};
int num = 3;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "table";
span[1] = "csv";
span[2] = "json";
obj2.Enum = list;
obj["output_format"] = obj2;
toolParameterSchema.Properties = obj;
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "source_path";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string path = args.GetProperty("source_path").GetString() ?? "";
string text = FileReadTool.ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(text))
{
return Task.FromResult(ToolResult.Fail("경로 접근 차단: " + text));
}
if (!File.Exists(text))
{
return Task.FromResult(ToolResult.Fail("파일 없음: " + text));
}
try
{
string text2 = Path.GetExtension(text).ToLowerInvariant();
List<Dictionary<string, string>> list = ((!(text2 == ".json")) ? LoadCsv(text) : LoadJson(text));
if (list.Count == 0)
{
return Task.FromResult(ToolResult.Fail("데이터가 비어있습니다."));
}
int count = list.Count;
if (args.TryGetProperty("filter", out var value))
{
string text3 = value.GetString() ?? "";
if (!string.IsNullOrWhiteSpace(text3))
{
list = ApplyFilter(list, text3);
}
}
List<Dictionary<string, string>> list4;
if (args.TryGetProperty("group_by", out var value2) && value2.ValueKind == JsonValueKind.Array)
{
List<string> list2 = new List<string>();
foreach (JsonElement item2 in value2.EnumerateArray())
{
list2.Add(item2.GetString() ?? "");
}
List<(string, string)> list3 = new List<(string, string)>();
if (args.TryGetProperty("aggregates", out var value3) && value3.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item3 in value3.EnumerateArray())
{
JsonElement value4;
string text4 = (item3.TryGetProperty("column", out value4) ? (value4.GetString() ?? "") : "");
JsonElement value5;
string item = (item3.TryGetProperty("function", out value5) ? (value5.GetString() ?? "count") : "count");
if (!string.IsNullOrEmpty(text4))
{
list3.Add((text4, item));
}
}
}
list4 = GroupAndAggregate(list, list2, list3);
}
else
{
list4 = list;
}
if (args.TryGetProperty("sort_by", out var value6))
{
string text5 = value6.GetString() ?? "";
if (!string.IsNullOrWhiteSpace(text5))
{
list4 = ApplySort(list4, text5);
}
}
if (args.TryGetProperty("top_n", out var value7) && value7.TryGetInt32(out var value8) && value8 > 0)
{
list4 = list4.Take(value8).ToList();
}
JsonElement value9;
string format = (args.TryGetProperty("output_format", out value9) ? (value9.GetString() ?? "table") : "table");
string value10 = FormatOutput(list4, format);
return Task.FromResult(ToolResult.Ok($"\ud83d\udcca 데이터 피벗 완료: {count}행 → 필터 후 {list.Count}행 → 결과 {list4.Count}행\n\n{value10}"));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail("데이터 피벗 실패: " + ex.Message));
}
}
private static List<Dictionary<string, string>> LoadCsv(string path)
{
string[] array = File.ReadAllLines(path, Encoding.UTF8);
if (array.Length < 2)
{
return new List<Dictionary<string, string>>();
}
List<string> list = ParseCsvLine(array[0]);
List<Dictionary<string, string>> list2 = new List<Dictionary<string, string>>();
for (int i = 1; i < array.Length; i++)
{
if (!string.IsNullOrWhiteSpace(array[i]))
{
List<string> list3 = ParseCsvLine(array[i]);
Dictionary<string, string> dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (int j = 0; j < list.Count && j < list3.Count; j++)
{
dictionary[list[j]] = list3[j];
}
list2.Add(dictionary);
}
}
return list2;
}
private static List<string> ParseCsvLine(string line)
{
List<string> list = new List<string>();
StringBuilder stringBuilder = new StringBuilder();
bool flag = false;
foreach (char c in line)
{
switch (c)
{
case '"':
flag = !flag;
continue;
case ',':
if (!flag)
{
list.Add(stringBuilder.ToString().Trim());
stringBuilder.Clear();
continue;
}
break;
}
stringBuilder.Append(c);
}
list.Add(stringBuilder.ToString().Trim());
return list;
}
private static List<Dictionary<string, string>> LoadJson(string path)
{
string json = File.ReadAllText(path, Encoding.UTF8);
JsonDocument jsonDocument = JsonDocument.Parse(json);
List<Dictionary<string, string>> list = new List<Dictionary<string, string>>();
JsonElement value;
JsonElement jsonElement = ((jsonDocument.RootElement.ValueKind == JsonValueKind.Array) ? jsonDocument.RootElement : (jsonDocument.RootElement.TryGetProperty("data", out value) ? value : jsonDocument.RootElement));
if (jsonElement.ValueKind != JsonValueKind.Array)
{
return list;
}
foreach (JsonElement item in jsonElement.EnumerateArray())
{
Dictionary<string, string> dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (JsonProperty item2 in item.EnumerateObject())
{
dictionary[item2.Name] = item2.Value.ToString();
}
list.Add(dictionary);
}
return list;
}
private static List<Dictionary<string, string>> ApplyFilter(List<Dictionary<string, string>> data, string filter)
{
string[] array = filter.Split(new string[2] { " AND ", " and " }, StringSplitOptions.TrimEntries);
List<Dictionary<string, string>> list = data;
string[] array2 = array;
foreach (string input in array2)
{
Match match = Regex.Match(input, "(\\w+)\\s*(==|!=|>=|<=|>|<|contains)\\s*(.+)");
if (!match.Success)
{
continue;
}
string col = match.Groups[1].Value;
string op = match.Groups[2].Value;
string val = match.Groups[3].Value.Trim().Trim('\'', '"');
list = list.Where(delegate(Dictionary<string, string> row)
{
if (!row.TryGetValue(col, out var value))
{
return false;
}
if (1 == 0)
{
}
double result2;
double result3;
double result4;
double result5;
double result6;
double result7;
double result8;
double result9;
bool result = op switch
{
"==" => value.Equals(val, StringComparison.OrdinalIgnoreCase),
"!=" => !value.Equals(val, StringComparison.OrdinalIgnoreCase),
"contains" => value.Contains(val, StringComparison.OrdinalIgnoreCase),
">" => double.TryParse(value, out result2) && double.TryParse(val, out result3) && result2 > result3,
"<" => double.TryParse(value, out result4) && double.TryParse(val, out result5) && result4 < result5,
">=" => double.TryParse(value, out result6) && double.TryParse(val, out result7) && result6 >= result7,
"<=" => double.TryParse(value, out result8) && double.TryParse(val, out result9) && result8 <= result9,
_ => true,
};
if (1 == 0)
{
}
return result;
}).ToList();
}
return list;
}
private static List<Dictionary<string, string>> GroupAndAggregate(List<Dictionary<string, string>> data, List<string> groupCols, List<(string Column, string Function)> aggregates)
{
IEnumerable<IGrouping<string, Dictionary<string, string>>> enumerable = data.GroupBy(delegate(Dictionary<string, string> row)
{
StringBuilder stringBuilder = new StringBuilder();
foreach (string groupCol in groupCols)
{
row.TryGetValue(groupCol, out var value2);
stringBuilder.Append(value2 ?? "").Append('|');
}
return stringBuilder.ToString();
});
List<Dictionary<string, string>> list = new List<Dictionary<string, string>>();
foreach (IGrouping<string, Dictionary<string, string>> item2 in enumerable)
{
Dictionary<string, string> dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
Dictionary<string, string> dictionary2 = item2.First();
foreach (string groupCol2 in groupCols)
{
dictionary[groupCol2] = (dictionary2.TryGetValue(groupCol2, out var value) ? value : "");
}
foreach (var aggregate in aggregates)
{
string aggCol = aggregate.Column;
string item = aggregate.Function;
string value2;
double result;
List<double> list2 = (from r in item2
select r.TryGetValue(aggCol, out value2) ? value2 : "" into v
where double.TryParse(v, out result)
select double.Parse(v)).ToList();
string text = item.ToLowerInvariant();
if (1 == 0)
{
}
double num;
switch (text)
{
case "sum":
num = list2.Sum();
break;
case "avg":
case "average":
num = ((list2.Count > 0) ? list2.Average() : 0.0);
break;
case "min":
num = ((list2.Count > 0) ? list2.Min() : 0.0);
break;
case "max":
num = ((list2.Count > 0) ? list2.Max() : 0.0);
break;
case "count":
num = item2.Count();
break;
default:
num = item2.Count();
break;
}
if (1 == 0)
{
}
double num2 = num;
string key = aggCol + "_" + item;
dictionary[key] = ((item == "count") ? ((int)num2).ToString() : num2.ToString("F2"));
}
if (aggregates.Count == 0)
{
dictionary["count"] = item2.Count().ToString();
}
list.Add(dictionary);
}
return list;
}
private static List<Dictionary<string, string>> ApplySort(List<Dictionary<string, string>> data, string sortBy)
{
bool flag = sortBy.StartsWith('-');
string col = sortBy.TrimStart('-');
return (flag ? data.OrderByDescending((Dictionary<string, string> r) => GetSortKey(r, col)) : data.OrderBy((Dictionary<string, string> r) => GetSortKey(r, col))).ToList();
}
private static object GetSortKey(Dictionary<string, string> row, string col)
{
if (!row.TryGetValue(col, out string value))
{
return "";
}
if (double.TryParse(value, out var result))
{
return result;
}
return value;
}
private static string FormatOutput(List<Dictionary<string, string>> data, string format)
{
if (data.Count == 0)
{
return "(결과 없음)";
}
List<string> list = data.SelectMany((Dictionary<string, string> r) => r.Keys).Distinct().ToList();
if (!(format == "json"))
{
if (format == "csv")
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine(string.Join(",", list));
foreach (Dictionary<string, string> row in data)
{
string value;
IEnumerable<string> values = list.Select((string c) => row.TryGetValue(c, out value) ? ("\"" + value + "\"") : "\"\"");
stringBuilder.AppendLine(string.Join(",", values));
}
return stringBuilder.ToString();
}
StringBuilder stringBuilder2 = new StringBuilder();
stringBuilder2.AppendLine("| " + string.Join(" | ", list) + " |");
stringBuilder2.AppendLine("| " + string.Join(" | ", list.Select((string _) => "---")) + " |");
foreach (Dictionary<string, string> row2 in data)
{
string value;
IEnumerable<string> values2 = list.Select((string c) => row2.TryGetValue(c, out value) ? value : "");
stringBuilder2.AppendLine("| " + string.Join(" | ", values2) + " |");
}
return stringBuilder2.ToString();
}
return JsonSerializer.Serialize(data, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
}
}

View File

@@ -0,0 +1,238 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Action to perform"
};
int num = 6;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "now";
span[1] = "parse";
span[2] = "diff";
span[3] = "add";
span[4] = "epoch";
span[5] = "format";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["date"] = new ToolProperty
{
Type = "string",
Description = "Date string (for parse/diff/add/format/epoch). For epoch: Unix timestamp in seconds."
};
dictionary["date2"] = new ToolProperty
{
Type = "string",
Description = "Second date string (for diff action)"
};
dictionary["amount"] = new ToolProperty
{
Type = "string",
Description = "Amount to add (for add action). E.g. '7' for 7 days"
};
ToolProperty obj2 = new ToolProperty
{
Type = "string",
Description = "Unit for add action"
};
num = 6;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
Span<string> span2 = CollectionsMarshal.AsSpan(list2);
span2[0] = "days";
span2[1] = "hours";
span2[2] = "minutes";
span2[3] = "seconds";
span2[4] = "months";
span2[5] = "years";
obj2.Enum = list2;
dictionary["unit"] = obj2;
dictionary["pattern"] = new ToolProperty
{
Type = "string",
Description = "Format pattern (for format action). E.g. 'yyyy-MM-dd HH:mm:ss', 'ddd MMM d yyyy'"
};
toolParameterSchema.Properties = dictionary;
num = 1;
List<string> list3 = new List<string>(num);
CollectionsMarshal.SetCount(list3, num);
CollectionsMarshal.AsSpan(list3)[0] = "action";
toolParameterSchema.Required = list3;
return toolParameterSchema;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string text = args.GetProperty("action").GetString() ?? "";
try
{
if (1 == 0)
{
}
ToolResult result = text switch
{
"now" => Now(),
"parse" => Parse(args),
"diff" => Diff(args),
"add" => Add(args),
"epoch" => Epoch(args),
"format" => FormatDate(args),
_ => ToolResult.Fail("Unknown action: " + text),
};
if (1 == 0)
{
}
return Task.FromResult(result);
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail("DateTime 오류: " + ex.Message));
}
}
private static ToolResult Now()
{
DateTime now = DateTime.Now;
DateTime utcNow = DateTime.UtcNow;
long value = new DateTimeOffset(utcNow).ToUnixTimeSeconds();
return ToolResult.Ok($"Local: {now:yyyy-MM-dd HH:mm:ss (ddd)} ({TimeZoneInfo.Local.DisplayName})\nUTC: {utcNow:yyyy-MM-dd HH:mm:ss}\nISO: {now:O}\nEpoch: {value}\nWeek: {CultureInfo.InvariantCulture.Calendar.GetWeekOfYear(now, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday)}");
}
private static ToolResult Parse(JsonElement args)
{
JsonElement value;
string text = (args.TryGetProperty("date", out value) ? (value.GetString() ?? "") : "");
if (string.IsNullOrEmpty(text))
{
return ToolResult.Fail("'date' parameter is required");
}
if (!DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.None, out var result) && !DateTime.TryParse(text, CultureInfo.CurrentCulture, DateTimeStyles.None, out result))
{
return ToolResult.Fail("Cannot parse date: '" + text + "'");
}
return ToolResult.Ok($"Parsed: {result:yyyy-MM-dd HH:mm:ss}\nDay: {result:dddd}\nISO: {result:O}\nEpoch: {new DateTimeOffset(result).ToUnixTimeSeconds()}");
}
private static ToolResult Diff(JsonElement args)
{
JsonElement value;
string text = (args.TryGetProperty("date", out value) ? (value.GetString() ?? "") : "");
JsonElement value2;
string text2 = (args.TryGetProperty("date2", out value2) ? (value2.GetString() ?? "") : "");
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(text2))
{
return ToolResult.Fail("'date' and 'date2' parameters are required");
}
if (!DateTime.TryParse(text, out var result))
{
return ToolResult.Fail("Cannot parse date: '" + text + "'");
}
if (!DateTime.TryParse(text2, out var result2))
{
return ToolResult.Fail("Cannot parse date: '" + text2 + "'");
}
TimeSpan timeSpan = result2 - result;
return ToolResult.Ok($"From: {result:yyyy-MM-dd HH:mm:ss}\nTo: {result2:yyyy-MM-dd HH:mm:ss}\n\nDifference:\n {Math.Abs(timeSpan.TotalDays):F1} days\n {Math.Abs(timeSpan.TotalHours):F1} hours\n {Math.Abs(timeSpan.TotalMinutes):F0} minutes\n {Math.Abs(timeSpan.TotalSeconds):F0} seconds\n ({((timeSpan.TotalDays >= 0.0) ? "forward" : "backward")})");
}
private static ToolResult Add(JsonElement args)
{
JsonElement value;
string text = (args.TryGetProperty("date", out value) ? (value.GetString() ?? "") : "");
JsonElement value2;
string text2 = (args.TryGetProperty("amount", out value2) ? (value2.GetString() ?? "0") : "0");
JsonElement value3;
string text3 = (args.TryGetProperty("unit", out value3) ? (value3.GetString() ?? "days") : "days");
if (string.IsNullOrEmpty(text))
{
return ToolResult.Fail("'date' parameter is required");
}
if (!DateTime.TryParse(text, out var result))
{
return ToolResult.Fail("Cannot parse date: '" + text + "'");
}
if (!double.TryParse(text2, out var result2))
{
return ToolResult.Fail("Invalid amount: '" + text2 + "'");
}
if (1 == 0)
{
}
DateTime dateTime = text3 switch
{
"days" => result.AddDays(result2),
"hours" => result.AddHours(result2),
"minutes" => result.AddMinutes(result2),
"seconds" => result.AddSeconds(result2),
"months" => result.AddMonths((int)result2),
"years" => result.AddYears((int)result2),
_ => result.AddDays(result2),
};
if (1 == 0)
{
}
DateTime value4 = dateTime;
return ToolResult.Ok($"Original: {result:yyyy-MM-dd HH:mm:ss}\nAdded: {result2} {text3}\nResult: {value4:yyyy-MM-dd HH:mm:ss} ({value4:dddd})");
}
private static ToolResult Epoch(JsonElement args)
{
JsonElement value;
string text = (args.TryGetProperty("date", out value) ? (value.GetString() ?? "") : "");
if (string.IsNullOrEmpty(text))
{
return ToolResult.Fail("'date' parameter is required");
}
if (long.TryParse(text, out var result))
{
DateTimeOffset dateTimeOffset = DateTimeOffset.FromUnixTimeSeconds(result);
return ToolResult.Ok($"Epoch: {result}\nUTC: {dateTimeOffset.UtcDateTime:yyyy-MM-dd HH:mm:ss}\nLocal: {dateTimeOffset.LocalDateTime:yyyy-MM-dd HH:mm:ss}");
}
if (DateTime.TryParse(text, out var result2))
{
long value2 = new DateTimeOffset(result2).ToUnixTimeSeconds();
return ToolResult.Ok($"Date: {result2:yyyy-MM-dd HH:mm:ss}\nEpoch: {value2}");
}
return ToolResult.Fail("Cannot parse: '" + text + "'");
}
private static ToolResult FormatDate(JsonElement args)
{
JsonElement value;
string text = (args.TryGetProperty("date", out value) ? (value.GetString() ?? "") : "");
JsonElement value2;
string text2 = (args.TryGetProperty("pattern", out value2) ? (value2.GetString() ?? "yyyy-MM-dd") : "yyyy-MM-dd");
if (string.IsNullOrEmpty(text))
{
return ToolResult.Fail("'date' parameter is required");
}
if (!DateTime.TryParse(text, out var result))
{
return ToolResult.Fail("Cannot parse date: '" + text + "'");
}
return ToolResult.Ok(result.ToString(text2, CultureInfo.InvariantCulture));
}
}

View File

@@ -0,0 +1,268 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Win32;
namespace AxCopilot.Services.Agent;
public class DevEnvDetectTool : IAgentTool
{
private static (DateTime Time, string Result)? _cache;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Detection category: all (default), ides, runtimes, build_tools"
};
int num = 4;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "all";
span[1] = "ides";
span[2] = "runtimes";
span[3] = "build_tools";
obj.Enum = list;
dictionary["category"] = obj;
toolParameterSchema.Properties = dictionary;
toolParameterSchema.Required = new List<string>();
return toolParameterSchema;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
JsonElement value;
string text = (args.TryGetProperty("category", out value) ? (value.GetString() ?? "all") : "all");
if (_cache.HasValue && (DateTime.UtcNow - _cache.Value.Time).TotalSeconds < 60.0)
{
return Task.FromResult(ToolResult.Ok(_cache.Value.Result));
}
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("=== 개발 환경 감지 보고서 ===\n");
if ((text == "all" || text == "ides") ? true : false)
{
stringBuilder.AppendLine("## IDE");
DetectIde(stringBuilder, "VS Code", DetectVsCode);
DetectIde(stringBuilder, "Visual Studio", DetectVisualStudio);
DetectIde(stringBuilder, "IntelliJ IDEA", () => DetectJetBrains("IntelliJ"));
DetectIde(stringBuilder, "PyCharm", () => DetectJetBrains("PyCharm"));
stringBuilder.AppendLine();
}
if ((text == "all" || text == "runtimes") ? true : false)
{
stringBuilder.AppendLine("## Language Runtimes");
DetectCommand(stringBuilder, "dotnet", "dotnet --version", "DOTNET_ROOT");
DetectCommand(stringBuilder, "python", "python --version", "PYTHON_HOME");
DetectCommand(stringBuilder, "conda", "conda --version", "CONDA_PREFIX");
DetectCommand(stringBuilder, "java", "java -version", "JAVA_HOME");
DetectCommand(stringBuilder, "node", "node --version", "NODE_HOME");
DetectCommand(stringBuilder, "npm", "npm --version", null);
DetectCommand(stringBuilder, "gcc", "gcc --version", null);
DetectCommand(stringBuilder, "g++", "g++ --version", null);
stringBuilder.AppendLine();
}
if ((text == "all" || text == "build_tools") ? true : false)
{
stringBuilder.AppendLine("## Build Tools");
DetectCommand(stringBuilder, "MSBuild", "msbuild -version", null);
DetectCommand(stringBuilder, "Maven", "mvn --version", "MAVEN_HOME");
DetectCommand(stringBuilder, "Gradle", "gradle --version", "GRADLE_HOME");
DetectCommand(stringBuilder, "CMake", "cmake --version", null);
DetectCommand(stringBuilder, "yarn", "yarn --version", null);
DetectCommand(stringBuilder, "pip", "pip --version", null);
stringBuilder.AppendLine();
}
string text2 = stringBuilder.ToString();
_cache = (DateTime.UtcNow, text2);
return Task.FromResult(ToolResult.Ok(text2));
}
private static void DetectIde(StringBuilder sb, string name, Func<string?> detector)
{
string text = detector();
sb.AppendLine((text != null) ? (" ✅ " + name + ": " + text) : (" ❌ " + name + ": 미설치"));
}
private static void DetectCommand(StringBuilder sb, string name, string versionCmd, string? envVar)
{
string text = null;
if (envVar != null)
{
text = Environment.GetEnvironmentVariable(envVar);
}
string text2 = RunQuick("where.exe", name);
string value = "";
if (text2 != null)
{
string[] array = versionCmd.Split(' ', 2);
string text3 = RunQuick(array[0], (array.Length > 1) ? array[1] : "");
if (text3 != null)
{
value = text3.Split('\n')[0].Trim();
}
}
if (text2 != null)
{
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(9, 3, stringBuilder);
handler.AppendLiteral(" ✅ ");
handler.AppendFormatted(name);
handler.AppendLiteral(": ");
handler.AppendFormatted(value);
handler.AppendLiteral(" (");
handler.AppendFormatted(text2.Split('\n')[0].Trim());
handler.AppendLiteral(")");
stringBuilder2.AppendLine(ref handler);
}
else if (text != null)
{
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(15, 3, stringBuilder);
handler.AppendLiteral(" ⚠ ");
handler.AppendFormatted(name);
handler.AppendLiteral(": 환경변수만 (");
handler.AppendFormatted(envVar);
handler.AppendLiteral("=");
handler.AppendFormatted(text);
handler.AppendLiteral(")");
stringBuilder3.AppendLine(ref handler);
}
else
{
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder4 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder);
handler.AppendLiteral(" ❌ ");
handler.AppendFormatted(name);
handler.AppendLiteral(": 미설치");
stringBuilder4.AppendLine(ref handler);
}
}
private static string? RunQuick(string exe, string args)
{
try
{
ProcessStartInfo startInfo = new ProcessStartInfo(exe, args)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process == null)
{
return null;
}
string text = process.StandardOutput.ReadToEnd();
string text2 = process.StandardError.ReadToEnd();
process.WaitForExit(5000);
string text3 = (string.IsNullOrWhiteSpace(text) ? text2 : text);
return string.IsNullOrWhiteSpace(text3) ? null : text3.Trim();
}
catch
{
return null;
}
}
private static string? DetectVsCode()
{
string text = RunQuick("where.exe", "code");
if (text != null)
{
return text.Split('\n')[0].Trim();
}
string folderPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string text2 = Path.Combine(folderPath, "Programs", "Microsoft VS Code", "Code.exe");
return File.Exists(text2) ? text2 : null;
}
private static string? DetectVisualStudio()
{
try
{
using RegistryKey registryKey = Registry.LocalMachine.OpenSubKey("SOFTWARE\\Microsoft\\VisualStudio\\SxS\\VS7");
if (registryKey != null)
{
foreach (string item in from n in registryKey.GetValueNames()
orderby n descending
select n)
{
string text = registryKey.GetValue(item)?.ToString();
if (!string.IsNullOrEmpty(text) && Directory.Exists(text))
{
return $"Visual Studio {item} ({text})";
}
}
}
using RegistryKey registryKey2 = Registry.LocalMachine.OpenSubKey("SOFTWARE\\WOW6432Node\\Microsoft\\VisualStudio\\SxS\\VS7");
if (registryKey2 != null)
{
foreach (string item2 in from n in registryKey2.GetValueNames()
orderby n descending
select n)
{
string text2 = registryKey2.GetValue(item2)?.ToString();
if (!string.IsNullOrEmpty(text2) && Directory.Exists(text2))
{
return $"Visual Studio {item2} ({text2})";
}
}
}
}
catch
{
}
string text3 = RunQuick("where.exe", "devenv");
return (text3 != null) ? text3.Split('\n')[0].Trim() : null;
}
private static string? DetectJetBrains(string product)
{
try
{
using RegistryKey registryKey = Registry.LocalMachine.OpenSubKey("SOFTWARE\\JetBrains\\" + product);
if (registryKey != null)
{
string[] array = (from n in registryKey.GetSubKeyNames()
orderby n descending
select n).ToArray();
if (array.Length != 0)
{
using RegistryKey registryKey2 = registryKey.OpenSubKey(array[0]);
string value = registryKey2?.GetValue("InstallDir")?.ToString() ?? registryKey2?.GetValue("")?.ToString();
if (!string.IsNullOrEmpty(value))
{
return $"{product} {array[0]} ({value})";
}
}
}
}
catch
{
}
return null;
}
}

View File

@@ -0,0 +1,250 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "File path to modify"
},
["new_content"] = new ToolProperty
{
Type = "string",
Description = "Proposed new content for the file"
},
["description"] = new ToolProperty
{
Type = "string",
Description = "Description of the changes (optional)"
}
}
};
int num = 2;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "path";
span[1] = "new_content";
obj.Required = list;
return obj;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string rawPath = args.GetProperty("path").GetString() ?? "";
string newContent = args.GetProperty("new_content").GetString() ?? "";
JsonElement d;
string description = (args.TryGetProperty("description", out d) ? (d.GetString() ?? "") : "");
string path = (Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath));
if (!context.IsPathAllowed(path))
{
return ToolResult.Fail("경로 접근 차단: " + path);
}
try
{
string originalContent = "";
bool isNewFile = !File.Exists(path);
if (!isNewFile)
{
originalContent = await File.ReadAllTextAsync(path, ct);
}
string[] originalLines = (from l in originalContent.Split('\n')
select l.TrimEnd('\r')).ToArray();
string[] newLines = (from l in newContent.Split('\n')
select l.TrimEnd('\r')).ToArray();
string diff = GenerateUnifiedDiff(originalLines, newLines, path);
StringBuilder sb = new StringBuilder();
sb.AppendLine("[PREVIEW_PENDING]");
StringBuilder stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler;
if (!string.IsNullOrEmpty(description))
{
stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder);
handler.AppendLiteral("변경 설명: ");
handler.AppendFormatted(description);
stringBuilder2.AppendLine(ref handler);
}
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 1, stringBuilder);
handler.AppendLiteral("파일: ");
handler.AppendFormatted(path);
stringBuilder3.AppendLine(ref handler);
sb.AppendLine(isNewFile ? "상태: 새 파일 생성" : "상태: 기존 파일 수정");
sb.AppendLine();
if (string.IsNullOrEmpty(diff))
{
sb.AppendLine("변경 사항 없음 — 내용이 동일합니다.");
}
else
{
sb.Append(diff);
}
if (!(await context.CheckWritePermissionAsync("diff_preview", path)))
{
return ToolResult.Ok($"사용자가 파일 변경을 거부했습니다.\n\n{sb}");
}
string dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
await File.WriteAllTextAsync(path, newContent, 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)
{
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("--- ");
handler.AppendFormatted(filePath);
handler.AppendLiteral(" (원본)");
stringBuilder3.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("+++ ");
handler.AppendFormatted(filePath);
handler.AppendLiteral(" (수정)");
stringBuilder4.AppendLine(ref handler);
List<string> list = ComputeLcs(original, modified);
int i = 0;
int j = 0;
int num = 0;
List<(int, int, int, int)> list2 = new List<(int, int, int, int)>();
while (i < original.Length || j < modified.Length)
{
if (num < list.Count && i < original.Length && j < modified.Length && original[i] == list[num] && modified[j] == list[num])
{
i++;
j++;
num++;
continue;
}
int item = i;
int item2 = j;
for (; i < original.Length && (num >= list.Count || original[i] != list[num]); i++)
{
}
for (; j < modified.Length && (num >= list.Count || modified[j] != list[num]); j++)
{
}
list2.Add((item, i, item2, j));
}
if (list2.Count == 0)
{
return "";
}
foreach (var item7 in list2)
{
int item3 = item7.Item1;
int item4 = item7.Item2;
int item5 = item7.Item3;
int item6 = item7.Item4;
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(11, 4, stringBuilder2);
handler.AppendLiteral("@@ -");
handler.AppendFormatted(item3 + 1);
handler.AppendLiteral(",");
handler.AppendFormatted(item4 - item3);
handler.AppendLiteral(" +");
handler.AppendFormatted(item5 + 1);
handler.AppendLiteral(",");
handler.AppendFormatted(item6 - item5);
handler.AppendLiteral(" @@");
stringBuilder5.AppendLine(ref handler);
for (int k = item3; k < item4; k++)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(1, 1, stringBuilder2);
handler.AppendLiteral("-");
handler.AppendFormatted(original[k]);
stringBuilder6.AppendLine(ref handler);
}
for (int l = item5; l < item6; l++)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(1, 1, stringBuilder2);
handler.AppendLiteral("+");
handler.AppendFormatted(modified[l]);
stringBuilder7.AppendLine(ref handler);
}
}
return stringBuilder.ToString();
}
private static List<string> ComputeLcs(string[] a, string[] b)
{
int num = a.Length;
int num2 = b.Length;
if ((long)num * (long)num2 > 10000000)
{
return new List<string>();
}
int[,] array = new int[num + 1, num2 + 1];
for (int num3 = num - 1; num3 >= 0; num3--)
{
for (int num4 = num2 - 1; num4 >= 0; num4--)
{
array[num3, num4] = ((a[num3] == b[num4]) ? (array[num3 + 1, num4 + 1] + 1) : Math.Max(array[num3 + 1, num4], array[num3, num4 + 1]));
}
}
List<string> list = new List<string>();
int num5 = 0;
int num6 = 0;
while (num5 < num && num6 < num2)
{
if (a[num5] == b[num6])
{
list.Add(a[num5]);
num5++;
num6++;
}
else if (array[num5 + 1, num6] >= array[num5, num6 + 1])
{
num5++;
}
else
{
num6++;
}
}
return list;
}
}

View File

@@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Comparison mode"
};
int num = 2;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "text";
span[1] = "file";
obj.Enum = list;
dictionary["mode"] = obj;
dictionary["left"] = new ToolProperty
{
Type = "string",
Description = "Left text content or file path"
};
dictionary["right"] = new ToolProperty
{
Type = "string",
Description = "Right text content or file path"
};
dictionary["left_label"] = new ToolProperty
{
Type = "string",
Description = "Label for left side (optional, default: 'left')"
};
dictionary["right_label"] = new ToolProperty
{
Type = "string",
Description = "Label for right side (optional, default: 'right')"
};
toolParameterSchema.Properties = dictionary;
num = 3;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
Span<string> span2 = CollectionsMarshal.AsSpan(list2);
span2[0] = "mode";
span2[1] = "left";
span2[2] = "right";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string text = args.GetProperty("mode").GetString() ?? "text";
string text2 = args.GetProperty("left").GetString() ?? "";
string text3 = args.GetProperty("right").GetString() ?? "";
JsonElement value;
string leftLabel = (args.TryGetProperty("left_label", out value) ? (value.GetString() ?? "left") : "left");
JsonElement value2;
string rightLabel = (args.TryGetProperty("right_label", out value2) ? (value2.GetString() ?? "right") : "right");
try
{
if (text == "file")
{
string text4 = (Path.IsPathRooted(text2) ? text2 : Path.Combine(context.WorkFolder, text2));
string text5 = (Path.IsPathRooted(text3) ? text3 : Path.Combine(context.WorkFolder, text3));
if (!File.Exists(text4))
{
return Task.FromResult(ToolResult.Fail("Left file not found: " + text4));
}
if (!File.Exists(text5))
{
return Task.FromResult(ToolResult.Fail("Right file not found: " + text5));
}
text2 = File.ReadAllText(text4);
text3 = File.ReadAllText(text5);
leftLabel = Path.GetFileName(text4);
rightLabel = Path.GetFileName(text5);
}
string[] left = (from l in text2.Split('\n')
select l.TrimEnd('\r')).ToArray();
string[] right = (from l in text3.Split('\n')
select l.TrimEnd('\r')).ToArray();
string text6 = GenerateUnifiedDiff(left, right, leftLabel, rightLabel);
if (string.IsNullOrEmpty(text6))
{
return Task.FromResult(ToolResult.Ok("No differences found — files/texts are identical."));
}
if (text6.Length > 10000)
{
text6 = text6.Substring(0, 10000) + "\n... (truncated)";
}
return Task.FromResult(ToolResult.Ok(text6));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail("Diff 오류: " + ex.Message));
}
}
private static string GenerateUnifiedDiff(string[] left, string[] right, string leftLabel, string rightLabel)
{
List<string> list = ComputeLcs(left, right);
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(4, 1, stringBuilder2);
handler.AppendLiteral("--- ");
handler.AppendFormatted(leftLabel);
stringBuilder3.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 1, stringBuilder2);
handler.AppendLiteral("+++ ");
handler.AppendFormatted(rightLabel);
stringBuilder4.AppendLine(ref handler);
int i = 0;
int j = 0;
int num = 0;
List<(int, int, int, int)> list2 = new List<(int, int, int, int)>();
while (i < left.Length || j < right.Length)
{
if (num < list.Count && i < left.Length && j < right.Length && left[i] == list[num] && right[j] == list[num])
{
i++;
j++;
num++;
continue;
}
int item = i;
int item2 = j;
for (; i < left.Length && (num >= list.Count || left[i] != list[num]); i++)
{
}
for (; j < right.Length && (num >= list.Count || right[j] != list[num]); j++)
{
}
list2.Add((item, i, item2, j));
}
if (list2.Count == 0)
{
return "";
}
foreach (var item7 in list2)
{
int item3 = item7.Item1;
int item4 = item7.Item2;
int item5 = item7.Item3;
int item6 = item7.Item4;
int num2 = Math.Max(0, item3 - 3);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(11, 4, stringBuilder2);
handler.AppendLiteral("@@ -");
handler.AppendFormatted(item3 + 1);
handler.AppendLiteral(",");
handler.AppendFormatted(item4 - item3);
handler.AppendLiteral(" +");
handler.AppendFormatted(item5 + 1);
handler.AppendLiteral(",");
handler.AppendFormatted(item6 - item5);
handler.AppendLiteral(" @@");
stringBuilder5.AppendLine(ref handler);
for (int k = item3; k < item4; k++)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(1, 1, stringBuilder2);
handler.AppendLiteral("-");
handler.AppendFormatted(left[k]);
stringBuilder6.AppendLine(ref handler);
}
for (int l = item5; l < item6; l++)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(1, 1, stringBuilder2);
handler.AppendLiteral("+");
handler.AppendFormatted(right[l]);
stringBuilder7.AppendLine(ref handler);
}
}
return stringBuilder.ToString();
}
private static List<string> ComputeLcs(string[] a, string[] b)
{
int num = a.Length;
int num2 = b.Length;
if ((long)num * (long)num2 > 10000000)
{
return new List<string>();
}
int[,] array = new int[num + 1, num2 + 1];
for (int num3 = num - 1; num3 >= 0; num3--)
{
for (int num4 = num2 - 1; num4 >= 0; num4--)
{
array[num3, num4] = ((a[num3] == b[num4]) ? (array[num3 + 1, num4 + 1] + 1) : Math.Max(array[num3 + 1, num4], array[num3, num4 + 1]));
}
}
List<string> list = new List<string>();
int num5 = 0;
int num6 = 0;
while (num5 < num && num6 < num2)
{
if (a[num5] == b[num6])
{
list.Add(a[num5]);
num5++;
num6++;
}
else if (array[num5 + 1, num6] >= array[num5, num6 + 1])
{
num5++;
}
else
{
num6++;
}
}
return list;
}
}

View File

@@ -0,0 +1,478 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> obj = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "Output file path. Relative to work folder."
},
["title"] = new ToolProperty
{
Type = "string",
Description = "Document title"
},
["sections"] = new ToolProperty
{
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 ToolProperty
{
Type = "object"
}
}
};
ToolProperty obj2 = new ToolProperty
{
Type = "string",
Description = "Output format: html, docx, markdown. Default: html"
};
int num = 3;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "html";
span[1] = "docx";
span[2] = "markdown";
obj2.Enum = list;
obj["format"] = obj2;
obj["mood"] = new ToolProperty
{
Type = "string",
Description = "Design theme for HTML output: modern, professional, creative, corporate, dashboard, etc. Default: professional"
};
obj["toc"] = new ToolProperty
{
Type = "boolean",
Description = "Auto-generate table of contents. Default: true"
};
obj["cover_subtitle"] = new ToolProperty
{
Type = "string",
Description = "Subtitle for cover page. If provided, a cover page is added."
};
obj["header"] = new ToolProperty
{
Type = "string",
Description = "Header text for DOCX output."
};
obj["footer"] = new ToolProperty
{
Type = "string",
Description = "Footer text for DOCX output. Use {page} for page number."
};
toolParameterSchema.Properties = obj;
num = 3;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
Span<string> span2 = CollectionsMarshal.AsSpan(list2);
span2[0] = "path";
span2[1] = "title";
span2[2] = "sections";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string path = args.GetProperty("path").GetString() ?? "";
string title = args.GetProperty("title").GetString() ?? "Document";
JsonElement fmt;
string format = (args.TryGetProperty("format", out fmt) ? (fmt.GetString() ?? "html") : "html");
JsonElement m;
string mood = (args.TryGetProperty("mood", out m) ? (m.GetString() ?? "professional") : "professional");
JsonElement tocVal;
bool useToc = !args.TryGetProperty("toc", out tocVal) || tocVal.GetBoolean();
JsonElement cs;
string coverSubtitle = (args.TryGetProperty("cover_subtitle", out cs) ? cs.GetString() : null);
JsonElement hdr;
string headerText = (args.TryGetProperty("header", out hdr) ? hdr.GetString() : null);
JsonElement ftr;
string footerText = (args.TryGetProperty("footer", out ftr) ? ftr.GetString() : null);
if (!args.TryGetProperty("sections", out var sectionsEl) || sectionsEl.ValueKind != JsonValueKind.Array)
{
return ToolResult.Fail("sections 배열이 필요합니다.");
}
List<(string Heading, string Content, int Level)> sections = new List<(string, string, int)>();
foreach (JsonElement sec in sectionsEl.EnumerateArray())
{
JsonElement h;
string heading = (sec.TryGetProperty("heading", out h) ? (h.GetString() ?? "") : "");
JsonElement c;
string content = (sec.TryGetProperty("content", out c) ? (c.GetString() ?? "") : "");
JsonElement lv;
int level = ((!sec.TryGetProperty("level", out lv)) ? 1 : lv.GetInt32());
if (!string.IsNullOrWhiteSpace(heading) || !string.IsNullOrWhiteSpace(content))
{
sections.Add((heading, content, level));
}
h = default(JsonElement);
c = default(JsonElement);
lv = default(JsonElement);
}
if (sections.Count == 0)
{
return ToolResult.Fail("조립할 섹션이 없습니다.");
}
string fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork")
{
fullPath = AgentContext.EnsureTimestampedPath(fullPath);
}
if (1 == 0)
{
}
string text = format;
string text2 = ((text == "docx") ? ".docx" : ((!(text == "markdown")) ? ".html" : ".md"));
if (1 == 0)
{
}
string ext = text2;
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);
}
string dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
try
{
string text3 = format;
text2 = text3;
string resultMsg = ((text2 == "docx") ? AssembleDocx(fullPath, title, sections, headerText, footerText) : ((!(text2 == "markdown")) ? AssembleHtml(fullPath, title, sections, mood, useToc, coverSubtitle) : AssembleMarkdown(fullPath, title, sections)));
int totalChars = sections.Sum<(string, string, int)>(((string Heading, string Content, int Level) s) => s.Content.Length);
int totalWords = sections.Sum<(string, string, int)>(((string Heading, string Content, int Level) s) => EstimateWordCount(s.Content));
int 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)
{
StringBuilder stringBuilder = new StringBuilder();
string css = TemplateService.GetCss(mood);
TemplateMood mood2 = TemplateService.GetMood(mood);
stringBuilder.AppendLine("<!DOCTYPE html>");
stringBuilder.AppendLine("<html lang=\"ko\">");
stringBuilder.AppendLine("<head>");
stringBuilder.AppendLine("<meta charset=\"utf-8\">");
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(15, 1, stringBuilder2);
handler.AppendLiteral("<title>");
handler.AppendFormatted(Escape(title));
handler.AppendLiteral("</title>");
stringBuilder3.AppendLine(ref handler);
stringBuilder.AppendLine("<style>");
stringBuilder.AppendLine(css);
stringBuilder.AppendLine("\n.assembled-doc { max-width: 900px; margin: 0 auto; padding: 40px 30px 60px; }\n.assembled-doc h1 { font-size: 28px; margin-bottom: 8px; }\n.assembled-doc h2 { font-size: 22px; margin-top: 36px; margin-bottom: 12px; border-bottom: 2px solid var(--accent, #4B5EFC); padding-bottom: 6px; }\n.assembled-doc h3 { font-size: 18px; margin-top: 24px; margin-bottom: 8px; }\n.assembled-doc .section-content { line-height: 1.8; margin-bottom: 20px; }\n.assembled-doc .toc { background: #f8f9fa; border-radius: 12px; padding: 20px 28px; margin: 24px 0 32px; }\n.assembled-doc .toc h3 { margin-top: 0; }\n.assembled-doc .toc a { color: inherit; text-decoration: none; }\n.assembled-doc .toc a:hover { text-decoration: underline; }\n.assembled-doc .toc ul { list-style: none; padding-left: 0; }\n.assembled-doc .toc li { padding: 4px 0; }\n.cover-page { text-align: center; padding: 120px 40px 80px; page-break-after: always; }\n.cover-page h1 { font-size: 36px; margin-bottom: 16px; }\n.cover-page .subtitle { font-size: 18px; color: #666; margin-bottom: 40px; }\n.cover-page .date { font-size: 14px; color: #999; }\n@media print { .cover-page { page-break-after: always; } .assembled-doc h2 { page-break-before: auto; } }\n");
stringBuilder.AppendLine("</style>");
stringBuilder.AppendLine("</head>");
stringBuilder.AppendLine("<body>");
if (!string.IsNullOrWhiteSpace(coverSubtitle))
{
stringBuilder.AppendLine("<div class=\"cover-page\">");
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("<h1>");
handler.AppendFormatted(Escape(title));
handler.AppendLiteral("</h1>");
stringBuilder4.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(28, 1, stringBuilder2);
handler.AppendLiteral("<div class=\"subtitle\">");
handler.AppendFormatted(Escape(coverSubtitle));
handler.AppendLiteral("</div>");
stringBuilder5.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(24, 1, stringBuilder2);
handler.AppendLiteral("<div class=\"date\">");
handler.AppendFormatted(DateTime.Now, "yyyy년 MM월 dd일");
handler.AppendLiteral("</div>");
stringBuilder6.AppendLine(ref handler);
stringBuilder.AppendLine("</div>");
}
stringBuilder.AppendLine("<div class=\"assembled-doc\">");
if (string.IsNullOrWhiteSpace(coverSubtitle))
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("<h1>");
handler.AppendFormatted(Escape(title));
handler.AppendLiteral("</h1>");
stringBuilder7.AppendLine(ref handler);
}
if (toc && sections.Count > 1)
{
stringBuilder.AppendLine("<div class=\"toc\">");
stringBuilder.AppendLine("<h3>\ud83d\udccb 목차</h3>");
stringBuilder.AppendLine("<ul>");
for (int i = 0; i < sections.Count; i++)
{
string value = ((sections[i].Level > 1) ? " style=\"padding-left:20px\"" : "");
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder8 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(33, 3, stringBuilder2);
handler.AppendLiteral("<li");
handler.AppendFormatted(value);
handler.AppendLiteral("><a href=\"#section-");
handler.AppendFormatted(i + 1);
handler.AppendLiteral("\">");
handler.AppendFormatted(Escape(sections[i].Heading));
handler.AppendLiteral("</a></li>");
stringBuilder8.AppendLine(ref handler);
}
stringBuilder.AppendLine("</ul>");
stringBuilder.AppendLine("</div>");
}
for (int j = 0; j < sections.Count; j++)
{
(string Heading, string Content, int Level) tuple = sections[j];
string item = tuple.Heading;
string item2 = tuple.Content;
int item3 = tuple.Level;
string value2 = ((item3 <= 1) ? "h2" : "h3");
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder9 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(19, 4, stringBuilder2);
handler.AppendLiteral("<");
handler.AppendFormatted(value2);
handler.AppendLiteral(" id=\"section-");
handler.AppendFormatted(j + 1);
handler.AppendLiteral("\">");
handler.AppendFormatted(Escape(item));
handler.AppendLiteral("</");
handler.AppendFormatted(value2);
handler.AppendLiteral(">");
stringBuilder9.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder10 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(35, 1, stringBuilder2);
handler.AppendLiteral("<div class=\"section-content\">");
handler.AppendFormatted(item2);
handler.AppendLiteral("</div>");
stringBuilder10.AppendLine(ref handler);
}
stringBuilder.AppendLine("</div>");
stringBuilder.AppendLine("</body>");
stringBuilder.AppendLine("</html>");
File.WriteAllText(path, stringBuilder.ToString(), Encoding.UTF8);
List<string> list = ValidateBasic(stringBuilder.ToString());
return (list.Count > 0) ? $" ⚠ 품질 검증 이슈 {list.Count}건: {string.Join("; ", list)}" : " ✓ 품질 검증 통과";
}
private string AssembleDocx(string path, string title, List<(string Heading, string Content, int Level)> sections, string? headerText, string? footerText)
{
using WordprocessingDocument wordprocessingDocument = WordprocessingDocument.Create(path, WordprocessingDocumentType.Document);
MainDocumentPart mainDocumentPart = wordprocessingDocument.AddMainDocumentPart();
mainDocumentPart.Document = new Document();
Body body = mainDocumentPart.Document.AppendChild(new Body());
Paragraph paragraph = new Paragraph();
Run run = new Run();
run.AppendChild(new RunProperties
{
Bold = new Bold(),
FontSize = new FontSize
{
Val = "48"
}
});
run.AppendChild(new Text(title));
paragraph.AppendChild(run);
body.AppendChild(paragraph);
body.AppendChild(new Paragraph());
foreach (var section in sections)
{
string item = section.Heading;
string item2 = section.Content;
int item3 = section.Level;
Paragraph paragraph2 = new Paragraph();
Run run2 = new Run();
run2.AppendChild(new RunProperties
{
Bold = new Bold(),
FontSize = new FontSize
{
Val = ((item3 <= 1) ? "32" : "28")
},
Color = new Color
{
Val = "2B579A"
}
});
run2.AppendChild(new Text(item));
paragraph2.AppendChild(run2);
body.AppendChild(paragraph2);
string[] array = StripHtmlTags(item2).Split('\n', StringSplitOptions.RemoveEmptyEntries);
string[] array2 = array;
foreach (string text in array2)
{
Paragraph paragraph3 = new Paragraph();
Run run3 = new Run();
run3.AppendChild(new Text(text.Trim())
{
Space = SpaceProcessingModeValues.Preserve
});
paragraph3.AppendChild(run3);
body.AppendChild(paragraph3);
}
body.AppendChild(new Paragraph());
}
return " ✓ DOCX 조립 완료";
}
private string AssembleMarkdown(string path, string title, List<(string Heading, string Content, int Level)> sections)
{
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(2, 1, stringBuilder2);
handler.AppendLiteral("# ");
handler.AppendFormatted(title);
stringBuilder3.AppendLine(ref handler);
stringBuilder.AppendLine();
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder2);
handler.AppendLiteral("*작성일: ");
handler.AppendFormatted(DateTime.Now, "yyyy-MM-dd");
handler.AppendLiteral("*");
stringBuilder4.AppendLine(ref handler);
stringBuilder.AppendLine();
if (sections.Count > 1)
{
stringBuilder.AppendLine("## 목차");
stringBuilder.AppendLine();
foreach (var section in sections)
{
string item = section.Heading;
string value = item.Replace(" ", "-").ToLowerInvariant();
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(7, 2, stringBuilder2);
handler.AppendLiteral("- [");
handler.AppendFormatted(item);
handler.AppendLiteral("](#");
handler.AppendFormatted(value);
handler.AppendLiteral(")");
stringBuilder5.AppendLine(ref handler);
}
stringBuilder.AppendLine();
stringBuilder.AppendLine("---");
stringBuilder.AppendLine();
}
foreach (var section2 in sections)
{
string item2 = section2.Heading;
string item3 = section2.Content;
int item4 = section2.Level;
string value2 = ((item4 <= 1) ? "##" : "###");
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(1, 2, stringBuilder2);
handler.AppendFormatted(value2);
handler.AppendLiteral(" ");
handler.AppendFormatted(item2);
stringBuilder6.AppendLine(ref handler);
stringBuilder.AppendLine();
stringBuilder.AppendLine(StripHtmlTags(item3));
stringBuilder.AppendLine();
}
File.WriteAllText(path, stringBuilder.ToString(), Encoding.UTF8);
return " ✓ Markdown 조립 완료";
}
private static int EstimateWordCount(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return 0;
}
string source = StripHtmlTags(text);
int num = source.Count((char c) => c == ' ');
int num2 = source.Count((char c) => c >= '가' && c <= '힣');
return num + 1 + num2 / 3;
}
private static string StripHtmlTags(string html)
{
if (string.IsNullOrEmpty(html))
{
return "";
}
return Regex.Replace(html, "<[^>]+>", " ").Replace("&nbsp;", " ").Replace("&amp;", "&")
.Replace("&lt;", "<")
.Replace("&gt;", ">")
.Replace(" ", " ")
.Trim();
}
private static List<string> ValidateBasic(string html)
{
List<string> list = new List<string>();
if (html.Length < 500)
{
list.Add("문서 내용이 매우 짧습니다 (500자 미만)");
}
Regex regex = new Regex("<h[23][^>]*>[^<]+</h[23]>\\s*<div class=\"section-content\">\\s*</div>", RegexOptions.IgnoreCase);
MatchCollection matchCollection = regex.Matches(html);
if (matchCollection.Count > 0)
{
list.Add($"빈 섹션 {matchCollection.Count}개 발견");
}
if (html.Contains("[TODO]", StringComparison.OrdinalIgnoreCase) || html.Contains("[PLACEHOLDER]", StringComparison.OrdinalIgnoreCase) || html.Contains("Lorem ipsum", StringComparison.OrdinalIgnoreCase))
{
list.Add("플레이스홀더 텍스트가 남아있습니다");
}
return list;
}
private static string Escape(string text)
{
return text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,757 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;
using DocumentFormat.OpenXml.Wordprocessing;
using UglyToad.PdfPig;
using UglyToad.PdfPig.Content;
namespace AxCopilot.Services.Agent;
public class DocumentReaderTool : IAgentTool
{
private static readonly HashSet<string> TextExtensions = new HashSet<string>(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 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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "Document file path (absolute or relative to work folder)"
},
["max_chars"] = new ToolProperty
{
Type = "integer",
Description = "Maximum characters to extract per chunk. Default: 8000. Use smaller values for summaries."
},
["offset"] = new ToolProperty
{
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 ToolProperty
{
Type = "string",
Description = "For Excel files: sheet name or 1-based index. Default: first sheet."
},
["pages"] = new ToolProperty
{
Type = "string",
Description = "For PDF files: page range to read (e.g., '1-5', '3', '10-20'). Default: all pages."
},
["section"] = new ToolProperty
{
Type = "string",
Description = "Extract specific section. 'references' = extract references/bibliography from PDF. 'abstract' = extract abstract."
}
}
};
int num = 1;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
CollectionsMarshal.AsSpan(list)[0] = "path";
obj.Required = list;
return obj;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("path", out var pathEl))
{
return ToolResult.Fail("path가 필요합니다.");
}
string path = pathEl.GetString() ?? "";
JsonElement mc;
int maxChars = (args.TryGetProperty("max_chars", out mc) ? GetIntValue(mc, 8000) : 8000);
JsonElement off;
int offset = (args.TryGetProperty("offset", out off) ? GetIntValue(off, 0) : 0);
JsonElement sh;
string sheetParam = (args.TryGetProperty("sheet", out sh) ? (sh.GetString() ?? "") : "");
JsonElement pg;
string pagesParam = (args.TryGetProperty("pages", out pg) ? (pg.GetString() ?? "") : "");
JsonElement sec;
string sectionParam = (args.TryGetProperty("section", out sec) ? (sec.GetString() ?? "") : "");
if (maxChars < 100)
{
maxChars = 8000;
}
if (offset < 0)
{
offset = 0;
}
string fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
{
return ToolResult.Fail("경로 접근 차단: " + fullPath);
}
if (!File.Exists(fullPath))
{
return ToolResult.Fail("파일이 존재하지 않습니다: " + fullPath);
}
string ext = Path.GetExtension(fullPath).ToLowerInvariant();
try
{
int extractMax = ((offset > 0) ? (offset + maxChars + 100) : maxChars);
string text = ext;
if (1 == 0)
{
}
string text2 = text 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),
_ => (!TextExtensions.Contains(ext)) ? null : (await ReadTextFile(fullPath, extractMax, ct)),
};
if (1 == 0)
{
}
string text3 = text2;
if (text3 == null)
{
return ToolResult.Fail("지원하지 않는 파일 형식: " + ext);
}
int totalExtracted = text3.Length;
if (offset > 0)
{
if (offset >= text3.Length)
{
return ToolResult.Ok($"[{Path.GetFileName(fullPath)}] offset {offset}은 문서 끝을 초과합니다 (전체 {text3.Length}자).", fullPath);
}
text2 = text3;
int num = offset;
text3 = text2.Substring(num, text2.Length - num);
}
bool hasMore = text3.Length > maxChars;
if (hasMore)
{
text3 = text3.Substring(0, maxChars);
}
FileInfo fileInfo = new FileInfo(fullPath);
string header = $"[{Path.GetFileName(fullPath)}] ({ext.TrimStart('.')}, {FormatSize(fileInfo.Length)})";
if (offset > 0)
{
header += $" — offset {offset}부터 {maxChars}자 읽음";
}
if (hasMore)
{
int 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" + text3, fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail("문서 읽기 실패: " + ex.Message);
}
}
private static string ReadPdf(string path, int maxChars, string pagesParam, string sectionParam)
{
StringBuilder stringBuilder = new StringBuilder();
using PdfDocument pdfDocument = PdfDocument.Open(path);
int numberOfPages = pdfDocument.NumberOfPages;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(8, 1, stringBuilder2);
handler.AppendLiteral("PDF: ");
handler.AppendFormatted(numberOfPages);
handler.AppendLiteral("페이지");
stringBuilder3.AppendLine(ref handler);
stringBuilder.AppendLine();
var (num, num2) = ParsePageRange(pagesParam, numberOfPages);
if (string.Equals(sectionParam, "references", StringComparison.OrdinalIgnoreCase))
{
return ExtractReferences(pdfDocument, numberOfPages, maxChars);
}
if (string.Equals(sectionParam, "abstract", StringComparison.OrdinalIgnoreCase))
{
return ExtractAbstract(pdfDocument, numberOfPages, maxChars);
}
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(15, 3, stringBuilder2);
handler.AppendLiteral("읽는 범위: ");
handler.AppendFormatted(num);
handler.AppendLiteral("-");
handler.AppendFormatted(num2);
handler.AppendLiteral(" / ");
handler.AppendFormatted(numberOfPages);
handler.AppendLiteral(" 페이지");
stringBuilder4.AppendLine(ref handler);
stringBuilder.AppendLine();
for (int i = num; i <= num2; i++)
{
if (stringBuilder.Length >= maxChars)
{
break;
}
UglyToad.PdfPig.Content.Page page = pdfDocument.GetPage(i);
string text = page.Text;
if (!string.IsNullOrWhiteSpace(text))
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(13, 1, stringBuilder2);
handler.AppendLiteral("--- Page ");
handler.AppendFormatted(i);
handler.AppendLiteral(" ---");
stringBuilder5.AppendLine(ref handler);
stringBuilder.AppendLine(text.Trim());
stringBuilder.AppendLine();
}
}
return Truncate(stringBuilder.ToString(), maxChars);
}
private static (int start, int end) ParsePageRange(string pagesParam, int totalPages)
{
if (string.IsNullOrWhiteSpace(pagesParam))
{
return (start: 1, end: totalPages);
}
if (int.TryParse(pagesParam.Trim(), out var result))
{
return (start: Math.Max(1, result), end: Math.Min(result, totalPages));
}
string[] array = pagesParam.Split('-', StringSplitOptions.TrimEntries);
if (array.Length == 2 && int.TryParse(array[0], out var result2) && int.TryParse(array[1], out var result3))
{
return (start: Math.Max(1, result2), end: Math.Min(result3, totalPages));
}
return (start: 1, end: totalPages);
}
private static string ExtractReferences(PdfDocument doc, int totalPages, int maxChars)
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("=== References / Bibliography ===");
stringBuilder.AppendLine();
string[] array = new string[2] { "(?i)^\\s*(References|Bibliography|Works\\s+Cited|Literature\\s+Cited|참고\\s*문헌|참조|인용\\s*문헌)\\s*$", "(?i)^(References|Bibliography|참고문헌)\\s*\\n" };
bool flag = false;
int num = totalPages;
while (num >= Math.Max(1, totalPages - 10) && !flag)
{
string text = doc.GetPage(num).Text;
if (!string.IsNullOrWhiteSpace(text))
{
string[] array2 = array;
foreach (string pattern in array2)
{
Match match = Regex.Match(text, pattern, RegexOptions.Multiline);
if (!match.Success)
{
continue;
}
int index = match.Index;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(12, 1, stringBuilder2);
handler.AppendLiteral("(Page ");
handler.AppendFormatted(num);
handler.AppendLiteral("부터 시작)");
stringBuilder3.AppendLine(ref handler);
string text2 = text;
int num2 = index;
stringBuilder.AppendLine(text2.Substring(num2, text2.Length - num2).Trim());
for (int j = num + 1; j <= totalPages; j++)
{
if (stringBuilder.Length >= maxChars)
{
break;
}
string text3 = doc.GetPage(j).Text;
if (!string.IsNullOrWhiteSpace(text3))
{
stringBuilder.AppendLine(text3.Trim());
}
}
flag = true;
break;
}
}
num--;
}
if (!flag)
{
stringBuilder.AppendLine("(References 섹션 헤더를 찾지 못했습니다. 마지막 3페이지를 반환합니다.)");
stringBuilder.AppendLine();
for (int k = Math.Max(1, totalPages - 2); k <= totalPages; k++)
{
if (stringBuilder.Length >= maxChars)
{
break;
}
string text4 = doc.GetPage(k).Text;
if (!string.IsNullOrWhiteSpace(text4))
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(13, 1, stringBuilder2);
handler.AppendLiteral("--- Page ");
handler.AppendFormatted(k);
handler.AppendLiteral(" ---");
stringBuilder4.AppendLine(ref handler);
stringBuilder.AppendLine(text4.Trim());
stringBuilder.AppendLine();
}
}
}
string text5 = stringBuilder.ToString();
List<string> list = ParseReferenceEntries(text5);
if (list.Count > 0)
{
StringBuilder stringBuilder5 = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder5;
StringBuilder stringBuilder6 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(26, 1, stringBuilder2);
handler.AppendLiteral("=== References (");
handler.AppendFormatted(list.Count);
handler.AppendLiteral("개 항목) ===\n");
stringBuilder6.AppendLine(ref handler);
for (int l = 0; l < list.Count; l++)
{
stringBuilder2 = stringBuilder5;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(3, 2, stringBuilder2);
handler.AppendLiteral("[");
handler.AppendFormatted(l + 1);
handler.AppendLiteral("] ");
handler.AppendFormatted(list[l]);
stringBuilder7.AppendLine(ref handler);
}
return Truncate(stringBuilder5.ToString(), maxChars);
}
return Truncate(text5, maxChars);
}
private static string ExtractAbstract(PdfDocument doc, int totalPages, int maxChars)
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("=== Abstract ===");
stringBuilder.AppendLine();
for (int i = 1; i <= Math.Min(3, totalPages); i++)
{
string text = doc.GetPage(i).Text;
if (!string.IsNullOrWhiteSpace(text))
{
Match match = Regex.Match(text, "(?i)(Abstract|초록|요약)\\s*\\n(.*?)(?=\\n\\s*(Keywords|Introduction|1\\.|서론|키워드|핵심어)\\s*[\\n:])", RegexOptions.Singleline);
if (match.Success)
{
stringBuilder.AppendLine(match.Groups[2].Value.Trim());
return Truncate(stringBuilder.ToString(), maxChars);
}
}
}
stringBuilder.AppendLine("(Abstract 섹션을 찾지 못했습니다. 첫 페이지를 반환합니다.)");
string text2 = doc.GetPage(1).Text;
if (!string.IsNullOrWhiteSpace(text2))
{
stringBuilder.AppendLine(text2.Trim());
}
return Truncate(stringBuilder.ToString(), maxChars);
}
private static List<string> ParseReferenceEntries(string text)
{
List<string> list = new List<string>();
string[] array = Regex.Split(text, "\\n\\s*\\[(\\d+)\\]\\s*");
if (array.Length > 3)
{
for (int i = 2; i < array.Length; i += 2)
{
string text2 = array[i].Trim().Replace("\n", " ").Replace(" ", " ");
if (text2.Length > 10)
{
list.Add(text2);
}
}
return list;
}
string[] array2 = Regex.Split(text, "\\n\\s*(\\d+)\\.\\s+");
if (array2.Length > 5)
{
for (int j = 2; j < array2.Length; j += 2)
{
string text3 = array2[j].Trim().Replace("\n", " ").Replace(" ", " ");
if (text3.Length > 10)
{
list.Add(text3);
}
}
return list;
}
return list;
}
private static string ReadBibTeX(string path, int maxChars)
{
string text = File.ReadAllText(path, Encoding.UTF8);
StringBuilder stringBuilder = new StringBuilder();
Regex regex = new Regex("@(\\w+)\\s*\\{\\s*([^,\\s]+)\\s*,\\s*(.*?)\\n\\s*\\}", RegexOptions.Singleline);
Regex regex2 = new Regex("(\\w+)\\s*=\\s*[\\{\"](.*?)[\\}\"]", RegexOptions.Singleline);
MatchCollection matchCollection = regex.Matches(text);
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(12, 1, stringBuilder2);
handler.AppendLiteral("BibTeX: ");
handler.AppendFormatted(matchCollection.Count);
handler.AppendLiteral("개 항목");
stringBuilder3.AppendLine(ref handler);
stringBuilder.AppendLine();
int num = 0;
foreach (Match item in matchCollection)
{
if (stringBuilder.Length >= maxChars)
{
break;
}
num++;
string value = item.Groups[1].Value;
string value2 = item.Groups[2].Value;
string value3 = item.Groups[3].Value;
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(6, 3, stringBuilder2);
handler.AppendLiteral("[");
handler.AppendFormatted(num);
handler.AppendLiteral("] @");
handler.AppendFormatted(value);
handler.AppendLiteral("{");
handler.AppendFormatted(value2);
handler.AppendLiteral("}");
stringBuilder4.AppendLine(ref handler);
MatchCollection matchCollection2 = regex2.Matches(value3);
foreach (Match item2 in matchCollection2)
{
string text2 = item2.Groups[1].Value.ToLower();
string value4 = item2.Groups[2].Value.Trim();
bool flag;
switch (text2)
{
case "author":
case "title":
case "journal":
case "booktitle":
case "year":
case "volume":
case "number":
case "pages":
case "doi":
case "publisher":
case "url":
flag = true;
break;
default:
flag = false;
break;
}
if (flag)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 2, stringBuilder2);
handler.AppendLiteral(" ");
handler.AppendFormatted(text2);
handler.AppendLiteral(": ");
handler.AppendFormatted(value4);
stringBuilder5.AppendLine(ref handler);
}
}
stringBuilder.AppendLine();
}
if (matchCollection.Count == 0)
{
stringBuilder.AppendLine("(BibTeX 항목을 파싱하지 못했습니다. 원문을 반환합니다.)");
stringBuilder.AppendLine(Truncate(text, maxChars - stringBuilder.Length));
}
return Truncate(stringBuilder.ToString(), maxChars);
}
private static string ReadRis(string path, int maxChars)
{
string[] array = File.ReadAllLines(path, Encoding.UTF8);
StringBuilder stringBuilder = new StringBuilder();
List<Dictionary<string, List<string>>> list = new List<Dictionary<string, List<string>>>();
Dictionary<string, List<string>> dictionary = null;
string[] array2 = array;
string key2;
foreach (string text in array2)
{
if (text.StartsWith("TY -"))
{
dictionary = new Dictionary<string, List<string>>();
list.Add(dictionary);
}
else if (text.StartsWith("ER -"))
{
dictionary = null;
continue;
}
if (dictionary != null && text.Length >= 6 && text[2] == ' ' && text[3] == ' ' && text[4] == '-' && text[5] == ' ')
{
string key = text.Substring(0, 2).Trim();
key2 = text;
string item = key2.Substring(6, key2.Length - 6).Trim();
if (!dictionary.ContainsKey(key))
{
dictionary[key] = new List<string>();
}
dictionary[key].Add(item);
}
}
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("RIS: ");
handler.AppendFormatted(list.Count);
handler.AppendLiteral("개 항목");
stringBuilder3.AppendLine(ref handler);
stringBuilder.AppendLine();
Dictionary<string, string> dictionary2 = 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 j = 0; j < list.Count; j++)
{
if (stringBuilder.Length >= maxChars)
{
break;
}
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(2, 1, stringBuilder2);
handler.AppendLiteral("[");
handler.AppendFormatted(j + 1);
handler.AppendLiteral("]");
stringBuilder4.AppendLine(ref handler);
Dictionary<string, List<string>> dictionary3 = list[j];
foreach (KeyValuePair<string, List<string>> item2 in dictionary3)
{
item2.Deconstruct(out key2, out var value);
string text2 = key2;
List<string> values = value;
string valueOrDefault = dictionary2.GetValueOrDefault(text2, text2);
if ((text2 == "AU" || text2 == "KW") ? true : false)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 2, stringBuilder2);
handler.AppendLiteral(" ");
handler.AppendFormatted(valueOrDefault);
handler.AppendLiteral(": ");
handler.AppendFormatted(string.Join("; ", values));
stringBuilder5.AppendLine(ref handler);
}
else
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 2, stringBuilder2);
handler.AppendLiteral(" ");
handler.AppendFormatted(valueOrDefault);
handler.AppendLiteral(": ");
handler.AppendFormatted(string.Join(" ", values));
stringBuilder6.AppendLine(ref handler);
}
}
stringBuilder.AppendLine();
}
return Truncate(stringBuilder.ToString(), maxChars);
}
private static string ReadDocx(string path, int maxChars)
{
StringBuilder stringBuilder = new StringBuilder();
using WordprocessingDocument wordprocessingDocument = WordprocessingDocument.Open(path, isEditable: false);
Body body = wordprocessingDocument.MainDocumentPart?.Document.Body;
if (body == null)
{
return "(빈 문서)";
}
foreach (Paragraph item in body.Elements<Paragraph>())
{
string innerText = item.InnerText;
if (!string.IsNullOrWhiteSpace(innerText))
{
stringBuilder.AppendLine(innerText);
if (stringBuilder.Length >= maxChars)
{
break;
}
}
}
return Truncate(stringBuilder.ToString(), maxChars);
}
private static string ReadXlsx(string path, string sheetParam, int maxChars)
{
StringBuilder stringBuilder = new StringBuilder();
using SpreadsheetDocument spreadsheetDocument = SpreadsheetDocument.Open(path, isEditable: false);
WorkbookPart workbookPart = spreadsheetDocument.WorkbookPart;
if (workbookPart == null)
{
return "(빈 스프레드시트)";
}
List<Sheet> list = workbookPart.Workbook.Sheets?.Elements<Sheet>().ToList() ?? new List<Sheet>();
if (list.Count == 0)
{
return "(시트 없음)";
}
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(14, 2, stringBuilder2);
handler.AppendLiteral("Excel: ");
handler.AppendFormatted(list.Count);
handler.AppendLiteral("개 시트 (");
handler.AppendFormatted(string.Join(", ", list.Select((Sheet s) => s.Name?.Value)));
handler.AppendLiteral(")");
stringBuilder3.AppendLine(ref handler);
stringBuilder.AppendLine();
Sheet sheet = null;
if (!string.IsNullOrEmpty(sheetParam))
{
sheet = ((!int.TryParse(sheetParam, out var result) || result < 1 || result > list.Count) ? list.FirstOrDefault((Sheet s) => string.Equals(s.Name?.Value, sheetParam, StringComparison.OrdinalIgnoreCase)) : list[result - 1]);
}
if (sheet == null)
{
sheet = list[0];
}
string text = sheet.Id?.Value;
if (text == null)
{
return "(시트 ID 없음)";
}
WorksheetPart worksheetPart = (WorksheetPart)workbookPart.GetPartById(text);
List<SharedStringItem> sharedStrings = workbookPart.SharedStringTablePart?.SharedStringTable.Elements<SharedStringItem>().ToList() ?? new List<SharedStringItem>();
List<Row> list2 = worksheetPart.Worksheet.Descendants<Row>().ToList();
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(10, 2, stringBuilder2);
handler.AppendLiteral("[");
handler.AppendFormatted(sheet.Name?.Value);
handler.AppendLiteral("] (");
handler.AppendFormatted(list2.Count);
handler.AppendLiteral(" rows)");
stringBuilder4.AppendLine(ref handler);
foreach (Row item in list2)
{
List<Cell> list3 = item.Elements<Cell>().ToList();
List<string> list4 = new List<string>();
foreach (Cell item2 in list3)
{
list4.Add(GetCellValue(item2, sharedStrings));
}
stringBuilder.AppendLine(string.Join("\t", list4));
if (stringBuilder.Length >= maxChars)
{
break;
}
}
return Truncate(stringBuilder.ToString(), maxChars);
}
private static string GetCellValue(Cell cell, List<SharedStringItem> sharedStrings)
{
string text = cell.CellValue?.Text ?? "";
CellValues? cellValues = cell.DataType?.Value;
CellValues sharedString = CellValues.SharedString;
if (cellValues.HasValue && cellValues.GetValueOrDefault() == sharedString && int.TryParse(text, out var result) && result >= 0 && result < sharedStrings.Count)
{
return sharedStrings[result].InnerText;
}
return text;
}
private static async Task<string> ReadTextFile(string path, int maxChars, CancellationToken ct)
{
return Truncate(await File.ReadAllTextAsync(path, Encoding.UTF8, ct), maxChars);
}
private static string Truncate(string text, int maxChars)
{
if (text.Length <= maxChars)
{
return text;
}
return text.Substring(0, maxChars) + "\n\n... (내용 잘림 — pages 또는 section 파라미터로 특정 부분을 읽을 수 있습니다)";
}
private static string FormatSize(long bytes)
{
if (1 == 0)
{
}
string result = ((bytes < 1024) ? $"{bytes} B" : ((bytes >= 1048576) ? $"{(double)bytes / 1048576.0:F1} MB" : $"{(double)bytes / 1024.0:F1} KB"));
if (1 == 0)
{
}
return result;
}
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 result))
{
return result;
}
return defaultValue;
}
}

View File

@@ -0,0 +1,217 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "Path to the document to review"
},
["expected_sections"] = new ToolProperty
{
Type = "array",
Description = "Optional list of expected section titles to verify presence",
Items = new ToolProperty
{
Type = "string"
}
}
}
};
int num = 1;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
CollectionsMarshal.AsSpan(list)[0] = "path";
obj.Required = list;
return obj;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("path", out var value))
{
return Task.FromResult(ToolResult.Fail("path가 필요합니다."));
}
string path = value.GetString() ?? "";
string text = FileReadTool.ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(text))
{
return Task.FromResult(ToolResult.Fail("경로 접근 차단: " + text));
}
if (!File.Exists(text))
{
return Task.FromResult(ToolResult.Fail("파일 없음: " + text));
}
string text2 = File.ReadAllText(text);
string text3 = Path.GetExtension(text).ToLowerInvariant();
List<string> list = new List<string>();
List<string> list2 = new List<string>();
int value2 = text2.Split('\n').Length;
int length = text2.Length;
list2.Add($"파일: {Path.GetFileName(text)} ({length:N0}자, {value2}줄)");
if (string.IsNullOrWhiteSpace(text2))
{
list.Add("[CRITICAL] 파일 내용이 비어있습니다");
return Task.FromResult(ToolResult.Ok(FormatReport(list2, list, new List<string>()), text));
}
string[] array = new string[8] { "TODO", "TBD", "FIXME", "Lorem ipsum", "[여기에", "[INSERT", "placeholder", "예시 텍스트" };
string[] array2 = array;
foreach (string text4 in array2)
{
if (text2.Contains(text4, StringComparison.OrdinalIgnoreCase))
{
list.Add("[WARNING] 플레이스홀더 텍스트 발견: \"" + text4 + "\"");
}
}
Regex regex = new Regex("\\d{4}[-년.]\\s*\\d{1,2}[-월.]\\s*\\d{1,2}[일]?");
foreach (Match item in regex.Matches(text2))
{
string s = Regex.Replace(item.Value, "[년월일\\s]", "-").TrimEnd('-');
if (DateTime.TryParse(s, out var result))
{
if (result > DateTime.Now.AddDays(365.0))
{
list.Add("[WARNING] 미래 날짜 감지: " + item.Value);
}
else if (result < DateTime.Now.AddYears(-50))
{
list.Add("[INFO] 매우 오래된 날짜: " + item.Value);
}
}
}
if ((text3 == ".html" || text3 == ".htm") ? true : false)
{
MatchCollection matchCollection = Regex.Matches(text2, "<h[23][^>]*>.*?</h[23]>\\s*<h[23]");
if (matchCollection.Count > 0)
{
list.Add($"[WARNING] 빈 섹션 {matchCollection.Count}개 감지 (헤딩 뒤 내용 없음)");
}
int count = Regex.Matches(text2, "<(table|div|section|article)\\b[^/]*>").Count;
int count2 = Regex.Matches(text2, "</(table|div|section|article)>").Count;
if (count != count2)
{
list.Add($"[WARNING] HTML 태그 불균형: 열림 {count}개, 닫힘 {count2}개");
}
MatchCollection matchCollection2 = Regex.Matches(text2, "<img\\b(?![^>]*\\balt\\s*=)[^>]*>");
if (matchCollection2.Count > 0)
{
list.Add($"[INFO] alt 속성 없는 이미지 {matchCollection2.Count}개");
}
int count3 = Regex.Matches(text2, "<h1\\b").Count;
int count4 = Regex.Matches(text2, "<h2\\b").Count;
list2.Add($"구조: h1={count3}, h2={count4}개 섹션");
}
if (args.TryGetProperty("expected_sections", out var value3) && value3.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item2 in value3.EnumerateArray())
{
string text5 = item2.GetString() ?? "";
if (!string.IsNullOrEmpty(text5) && !text2.Contains(text5, StringComparison.OrdinalIgnoreCase))
{
list.Add("[MISSING] 기대 섹션 누락: \"" + text5 + "\"");
}
}
}
IEnumerable<IGrouping<string, string>> source = from text6 in Regex.Split(text2, "[.!?。]\\s+")
where text6.Length > 20
group text6 by text6.Trim() into g
where g.Count() >= 3
select g;
foreach (IGrouping<string, string> item3 in source.Take(3))
{
list.Add($"[WARNING] 반복 텍스트 ({item3.Count()}회): \"{item3.Key.Substring(0, Math.Min(50, item3.Key.Length))}...\"");
}
List<string> list3 = new List<string>();
if (list.Count == 0)
{
list3.Add("문서 검증 통과 — 구조적 이슈가 발견되지 않았습니다.");
}
else
{
list3.Add($"총 {list.Count}개 이슈 발견. 수정 후 다시 검증하세요.");
if (list.Any((string text6) => text6.Contains("플레이스홀더")))
{
list3.Add("플레이스홀더를 실제 내용으로 교체하세요.");
}
if (list.Any((string text6) => text6.Contains("빈 섹션")))
{
list3.Add("빈 섹션에 내용을 추가하거나 불필요한 헤딩을 제거하세요.");
}
}
return Task.FromResult(ToolResult.Ok(FormatReport(list2, list, list3), text));
}
private static string FormatReport(List<string> stats, List<string> issues, List<string> suggestions)
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("=== 문서 검증 보고서 ===\n");
foreach (string stat in stats)
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2);
handler.AppendLiteral("\ud83d\udcca ");
handler.AppendFormatted(stat);
stringBuilder3.AppendLine(ref handler);
}
stringBuilder.AppendLine();
if (issues.Count == 0)
{
stringBuilder.AppendLine("✅ 이슈 없음 — 문서가 정상입니다.");
}
else
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(13, 1, stringBuilder2);
handler.AppendLiteral("⚠ 발견된 이슈 (");
handler.AppendFormatted(issues.Count);
handler.AppendLiteral("건):");
stringBuilder4.AppendLine(ref handler);
foreach (string issue in issues)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(2, 1, stringBuilder2);
handler.AppendLiteral(" ");
handler.AppendFormatted(issue);
stringBuilder5.AppendLine(ref handler);
}
}
stringBuilder.AppendLine();
foreach (string suggestion in suggestions)
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2);
handler.AppendLiteral("\ud83d\udca1 ");
handler.AppendFormatted(suggestion);
stringBuilder6.AppendLine(ref handler);
}
return stringBuilder.ToString();
}
}

View File

@@ -0,0 +1,730 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "Output file path (.docx). Relative to work folder."
},
["title"] = new ToolProperty
{
Type = "string",
Description = "Document title (optional)."
},
["sections"] = new ToolProperty
{
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\"]}\nBody text supports inline formatting: **bold**, *italic*, `code`.",
Items = new ToolProperty
{
Type = "object"
}
},
["header"] = new ToolProperty
{
Type = "string",
Description = "Header text shown at top of every page (optional)."
},
["footer"] = new ToolProperty
{
Type = "string",
Description = "Footer text. Use {page} for page number. Default: 'AX Copilot · {page}' if header is set."
},
["page_numbers"] = new ToolProperty
{
Type = "boolean",
Description = "Show page numbers in footer. Default: true if header or footer is set."
}
}
};
int num = 2;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "path";
span[1] = "sections";
obj.Required = list;
return obj;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string path = args.GetProperty("path").GetString() ?? "";
JsonElement t;
string title = (args.TryGetProperty("title", out t) ? (t.GetString() ?? "") : "");
JsonElement hdr;
string headerText = (args.TryGetProperty("header", out hdr) ? hdr.GetString() : null);
JsonElement ftr;
string footerText = (args.TryGetProperty("footer", out ftr) ? ftr.GetString() : null);
JsonElement pn;
bool showPageNumbers = (args.TryGetProperty("page_numbers", out pn) ? pn.GetBoolean() : (headerText != null || footerText != null));
string 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
{
JsonElement sections = args.GetProperty("sections");
string dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
using WordprocessingDocument doc = WordprocessingDocument.Create(fullPath, WordprocessingDocumentType.Document);
MainDocumentPart mainPart = doc.AddMainDocumentPart();
mainPart.Document = new Document();
Body 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 = 6u,
Color = "4472C4",
Space = 1u
}),
SpacingBetweenLines = new SpacingBetweenLines
{
After = "300"
}
}));
}
int sectionCount = 0;
int tableCount = 0;
foreach (JsonElement section in sections.EnumerateArray())
{
JsonElement bt;
switch ((!section.TryGetProperty("type", out bt)) ? null : bt.GetString()?.ToLower())
{
case "pagebreak":
body.Append(CreatePageBreak());
continue;
case "table":
body.Append(CreateTable(section));
tableCount++;
continue;
case "list":
AppendList(body, section);
continue;
}
JsonElement h;
string heading = (section.TryGetProperty("heading", out h) ? (h.GetString() ?? "") : "");
JsonElement b;
string bodyText = (section.TryGetProperty("body", out b) ? (b.GetString() ?? "") : "");
JsonElement lv;
int level = ((!section.TryGetProperty("level", out lv)) ? 1 : lv.GetInt32());
if (!string.IsNullOrEmpty(heading))
{
body.Append(CreateHeadingParagraph(heading, level));
}
if (!string.IsNullOrEmpty(bodyText))
{
string[] array = bodyText.Split('\n');
foreach (string line in array)
{
body.Append(CreateBodyParagraph(line));
}
}
sectionCount++;
bt = default(JsonElement);
h = default(JsonElement);
b = default(JsonElement);
lv = default(JsonElement);
}
mainPart.Document.Save();
List<string> 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)
{
Paragraph paragraph = new Paragraph();
paragraph.ParagraphProperties = new ParagraphProperties
{
Justification = new Justification
{
Val = JustificationValues.Center
},
SpacingBetweenLines = new SpacingBetweenLines
{
After = "100"
}
};
Run run = new Run(new Text(text));
run.RunProperties = new RunProperties
{
Bold = new Bold(),
FontSize = new FontSize
{
Val = "44"
},
Color = new Color
{
Val = "1F3864"
}
};
paragraph.Append(run);
return paragraph;
}
private static Paragraph CreateHeadingParagraph(string text, int level)
{
Paragraph paragraph = new Paragraph();
string text2 = ((level <= 1) ? "32" : "26");
string text3 = ((level <= 1) ? "2E74B5" : "404040");
paragraph.ParagraphProperties = new ParagraphProperties
{
SpacingBetweenLines = new SpacingBetweenLines
{
Before = ((level <= 1) ? "360" : "240"),
After = "120"
}
};
if (level <= 1)
{
paragraph.ParagraphProperties.ParagraphBorders = new ParagraphBorders(new BottomBorder
{
Val = BorderValues.Single,
Size = 4u,
Color = "B4C6E7",
Space = 1u
});
}
Run run = new Run(new Text(text));
run.RunProperties = new RunProperties
{
Bold = new Bold(),
FontSize = new FontSize
{
Val = text2
},
Color = new Color
{
Val = text3
}
};
paragraph.Append(run);
return paragraph;
}
private static Paragraph CreateBodyParagraph(string text)
{
Paragraph paragraph = new Paragraph();
paragraph.ParagraphProperties = new ParagraphProperties
{
SpacingBetweenLines = new SpacingBetweenLines
{
Line = "360"
}
};
AppendFormattedRuns(paragraph, text);
return paragraph;
}
private static void AppendFormattedRuns(Paragraph para, string text)
{
Regex regex = new Regex("\\*\\*(.+?)\\*\\*|\\*(.+?)\\*|`(.+?)`");
int num = 0;
foreach (Match item in regex.Matches(text))
{
if (item.Index > num)
{
OpenXmlElement[] array = new OpenXmlElement[1];
int num2 = num;
array[0] = CreateRun(text.Substring(num2, item.Index - num2));
para.Append(array);
}
if (item.Groups[1].Success)
{
Run run = CreateRun(item.Groups[1].Value);
Run run2 = run;
if (run2.RunProperties == null)
{
RunProperties runProperties = (run2.RunProperties = new RunProperties());
}
run.RunProperties.Bold = new Bold();
para.Append(run);
}
else if (item.Groups[2].Success)
{
Run run3 = CreateRun(item.Groups[2].Value);
Run run2 = run3;
if (run2.RunProperties == null)
{
RunProperties runProperties = (run2.RunProperties = new RunProperties());
}
run3.RunProperties.Italic = new Italic();
para.Append(run3);
}
else if (item.Groups[3].Success)
{
Run run4 = CreateRun(item.Groups[3].Value);
Run run2 = run4;
if (run2.RunProperties == null)
{
RunProperties runProperties = (run2.RunProperties = new RunProperties());
}
run4.RunProperties.RunFonts = new RunFonts
{
Ascii = "Consolas",
HighAnsi = "Consolas"
};
run4.RunProperties.FontSize = new FontSize
{
Val = "20"
};
run4.RunProperties.Shading = new Shading
{
Val = ShadingPatternValues.Clear,
Fill = "F2F2F2",
Color = "auto"
};
para.Append(run4);
}
num = item.Index + item.Length;
}
if (num < text.Length)
{
OpenXmlElement[] array2 = new OpenXmlElement[1];
int num2 = num;
array2[0] = CreateRun(text.Substring(num2, text.Length - num2));
para.Append(array2);
}
if (num == 0 && text.Length == 0)
{
para.Append(CreateRun(""));
}
}
private static Run CreateRun(string text)
{
Run run = new Run(new Text(text)
{
Space = SpaceProcessingModeValues.Preserve
});
run.RunProperties = new RunProperties
{
FontSize = new FontSize
{
Val = "22"
}
};
return run;
}
private static Table CreateTable(JsonElement section)
{
JsonElement value;
JsonElement jsonElement = (section.TryGetProperty("headers", out value) ? value : default(JsonElement));
JsonElement value2;
JsonElement jsonElement2 = (section.TryGetProperty("rows", out value2) ? value2 : default(JsonElement));
JsonElement value3;
string text = (section.TryGetProperty("style", out value3) ? (value3.GetString() ?? "striped") : "striped");
Table table = new Table();
TableProperties newChild = new TableProperties(new TableBorders(new TopBorder
{
Val = BorderValues.Single,
Size = 4u,
Color = "D9D9D9"
}, new BottomBorder
{
Val = BorderValues.Single,
Size = 4u,
Color = "D9D9D9"
}, new LeftBorder
{
Val = BorderValues.Single,
Size = 4u,
Color = "D9D9D9"
}, new RightBorder
{
Val = BorderValues.Single,
Size = 4u,
Color = "D9D9D9"
}, new InsideHorizontalBorder
{
Val = BorderValues.Single,
Size = 4u,
Color = "D9D9D9"
}, new InsideVerticalBorder
{
Val = BorderValues.Single,
Size = 4u,
Color = "D9D9D9"
}), new TableWidth
{
Width = "5000",
Type = TableWidthUnitValues.Pct
});
table.AppendChild(newChild);
if (jsonElement.ValueKind == JsonValueKind.Array)
{
TableRow tableRow = new TableRow();
foreach (JsonElement item in jsonElement.EnumerateArray())
{
TableCell tableCell = new TableCell();
tableCell.TableCellProperties = new TableCellProperties
{
Shading = new Shading
{
Val = ShadingPatternValues.Clear,
Fill = "2E74B5",
Color = "auto"
},
TableCellVerticalAlignment = new TableCellVerticalAlignment
{
Val = TableVerticalAlignmentValues.Center
}
};
Paragraph paragraph = new Paragraph(new Run(new Text(item.GetString() ?? ""))
{
RunProperties = new RunProperties
{
Bold = new Bold(),
FontSize = new FontSize
{
Val = "20"
},
Color = new Color
{
Val = "FFFFFF"
}
}
});
paragraph.ParagraphProperties = new ParagraphProperties
{
SpacingBetweenLines = new SpacingBetweenLines
{
Before = "40",
After = "40"
}
};
tableCell.Append(paragraph);
tableRow.Append(tableCell);
}
table.Append(tableRow);
}
if (jsonElement2.ValueKind == JsonValueKind.Array)
{
int num = 0;
foreach (JsonElement item2 in jsonElement2.EnumerateArray())
{
TableRow tableRow2 = new TableRow();
foreach (JsonElement item3 in item2.EnumerateArray())
{
TableCell tableCell2 = new TableCell();
if (text == "striped" && num % 2 == 0)
{
tableCell2.TableCellProperties = new TableCellProperties
{
Shading = new Shading
{
Val = ShadingPatternValues.Clear,
Fill = "F2F7FB",
Color = "auto"
}
};
}
Paragraph paragraph2 = new Paragraph(new Run(new Text(item3.ToString())
{
Space = SpaceProcessingModeValues.Preserve
})
{
RunProperties = new RunProperties
{
FontSize = new FontSize
{
Val = "20"
}
}
});
paragraph2.ParagraphProperties = new ParagraphProperties
{
SpacingBetweenLines = new SpacingBetweenLines
{
Before = "20",
After = "20"
}
};
tableCell2.Append(paragraph2);
tableRow2.Append(tableCell2);
}
table.Append(tableRow2);
num++;
}
}
return table;
}
private static void AppendList(Body body, JsonElement section)
{
JsonElement value;
JsonElement jsonElement = (section.TryGetProperty("items", out value) ? value : default(JsonElement));
JsonElement value2;
string text = (section.TryGetProperty("style", out value2) ? (value2.GetString() ?? "bullet") : "bullet");
if (jsonElement.ValueKind != JsonValueKind.Array)
{
return;
}
int num = 1;
foreach (JsonElement item in jsonElement.EnumerateArray())
{
string text2 = item.GetString() ?? item.ToString();
string text3 = ((text == "number") ? $"{num}. " : "• ");
Paragraph paragraph = new Paragraph();
paragraph.ParagraphProperties = new ParagraphProperties
{
Indentation = new Indentation
{
Left = "720"
},
SpacingBetweenLines = new SpacingBetweenLines
{
Line = "320"
}
};
Run run = new Run(new Text(text3)
{
Space = SpaceProcessingModeValues.Preserve
});
run.RunProperties = new RunProperties
{
FontSize = new FontSize
{
Val = "22"
},
Bold = ((text == "number") ? new Bold() : null)
};
paragraph.Append(run);
Run run2 = new Run(new Text(text2)
{
Space = SpaceProcessingModeValues.Preserve
});
run2.RunProperties = new RunProperties
{
FontSize = new FontSize
{
Val = "22"
}
};
paragraph.Append(run2);
body.Append(paragraph);
num++;
}
}
private static Paragraph CreatePageBreak()
{
Paragraph paragraph = new Paragraph();
Run run = new Run(new Break
{
Type = BreakValues.Page
});
paragraph.Append(run);
return paragraph;
}
private static void AddHeaderFooter(MainDocumentPart mainPart, Body body, string? headerText, string? footerText, bool showPageNumbers)
{
if (!string.IsNullOrEmpty(headerText))
{
HeaderPart headerPart = mainPart.AddNewPart<HeaderPart>();
Header header = new Header();
Paragraph paragraph = new Paragraph(new Run(new Text(headerText))
{
RunProperties = new RunProperties
{
FontSize = new FontSize
{
Val = "18"
},
Color = new Color
{
Val = "808080"
}
}
});
paragraph.ParagraphProperties = new ParagraphProperties
{
Justification = new Justification
{
Val = JustificationValues.Right
}
};
header.Append(paragraph);
headerPart.Header = header;
SectionProperties sectionProperties = body.GetFirstChild<SectionProperties>() ?? body.AppendChild(new SectionProperties());
sectionProperties.Append(new HeaderReference
{
Type = HeaderFooterValues.Default,
Id = mainPart.GetIdOfPart(headerPart)
});
}
if (!(!string.IsNullOrEmpty(footerText) || showPageNumbers))
{
return;
}
FooterPart footerPart = mainPart.AddNewPart<FooterPart>();
Footer footer = new Footer();
Paragraph paragraph2 = new Paragraph();
paragraph2.ParagraphProperties = new ParagraphProperties
{
Justification = new Justification
{
Val = JustificationValues.Center
}
};
string text = footerText ?? "AX Copilot";
if (showPageNumbers)
{
if (text.Contains("{page}"))
{
string[] array = text.Split("{page}");
paragraph2.Append(CreateFooterRun(array[0]));
paragraph2.Append(CreatePageNumberRun());
if (array.Length > 1)
{
paragraph2.Append(CreateFooterRun(array[1]));
}
}
else
{
paragraph2.Append(CreateFooterRun(text + " · "));
paragraph2.Append(CreatePageNumberRun());
}
}
else
{
paragraph2.Append(CreateFooterRun(text));
}
footer.Append(paragraph2);
footerPart.Footer = footer;
SectionProperties sectionProperties2 = body.GetFirstChild<SectionProperties>() ?? body.AppendChild(new SectionProperties());
sectionProperties2.Append(new FooterReference
{
Type = HeaderFooterValues.Default,
Id = mainPart.GetIdOfPart(footerPart)
});
}
private static Run CreateFooterRun(string text)
{
return new Run(new Text(text)
{
Space = SpaceProcessingModeValues.Preserve
})
{
RunProperties = new RunProperties
{
FontSize = new FontSize
{
Val = "16"
},
Color = new Color
{
Val = "999999"
}
}
};
}
private static Run CreatePageNumberRun()
{
Run 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,275 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Action: detect, convert, list"
};
int num = 3;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "detect";
span[1] = "convert";
span[2] = "list";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["path"] = new ToolProperty
{
Type = "string",
Description = "File path"
};
dictionary["from_encoding"] = new ToolProperty
{
Type = "string",
Description = "Source encoding name (e.g. 'euc-kr', 'shift-jis'). Auto-detected if omitted."
};
dictionary["to_encoding"] = new ToolProperty
{
Type = "string",
Description = "Target encoding name (default: 'utf-8')"
};
toolParameterSchema.Properties = dictionary;
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "action";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string action = args.GetProperty("action").GetString() ?? "";
if (action == "list")
{
return ListEncodings();
}
JsonElement pv;
string rawPath = (args.TryGetProperty("path", out pv) ? (pv.GetString() ?? "") : "");
if (string.IsNullOrEmpty(rawPath))
{
return ToolResult.Fail("'path'가 필요합니다.");
}
string 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
{
if (1 == 0)
{
}
string text = action;
ToolResult result = ((text == "detect") ? DetectEncoding(path) : ((!(text == "convert")) ? ToolResult.Fail("Unknown action: " + action) : (await ConvertEncoding(path, args, context))));
if (1 == 0)
{
}
return result;
}
catch (Exception ex)
{
return ToolResult.Fail("인코딩 처리 오류: " + ex.Message);
}
}
private static ToolResult DetectEncoding(string path)
{
byte[] array = File.ReadAllBytes(path);
Encoding encoding = DetectEncodingFromBytes(array);
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(6, 1, stringBuilder2);
handler.AppendLiteral("File: ");
handler.AppendFormatted(Path.GetFileName(path));
stringBuilder3.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(12, 1, stringBuilder2);
handler.AppendLiteral("Size: ");
handler.AppendFormatted(array.Length, "N0");
handler.AppendLiteral(" bytes");
stringBuilder4.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(19, 1, stringBuilder2);
handler.AppendLiteral("Detected Encoding: ");
handler.AppendFormatted(encoding.EncodingName);
stringBuilder5.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder2);
handler.AppendLiteral("Code Page: ");
handler.AppendFormatted(encoding.CodePage);
stringBuilder6.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(13, 1, stringBuilder2);
handler.AppendLiteral("BOM Present: ");
handler.AppendFormatted(HasBom(array));
stringBuilder7.AppendLine(ref handler);
return ToolResult.Ok(stringBuilder.ToString());
}
private static async Task<ToolResult> ConvertEncoding(string path, JsonElement args, AgentContext context)
{
JsonElement te;
string toName = (args.TryGetProperty("to_encoding", out te) ? (te.GetString() ?? "utf-8") : "utf-8");
if (!(await context.CheckWritePermissionAsync("encoding_tool", path)))
{
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
{
byte[] rawBytes = File.ReadAllBytes(path);
fromEnc = DetectEncodingFromBytes(rawBytes);
}
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
Encoding toEnc = Encoding.GetEncoding(toName);
string content = File.ReadAllText(path, fromEnc);
File.WriteAllText(path, content, toEnc);
return ToolResult.Ok($"변환 완료: {fromEnc.EncodingName} → {toEnc.EncodingName}\nFile: {path}", path);
}
private static ToolResult ListEncodings()
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("주요 인코딩 목록:");
stringBuilder.AppendLine(" utf-8 — UTF-8 (유니코드, 기본)");
stringBuilder.AppendLine(" utf-16 — UTF-16 LE");
stringBuilder.AppendLine(" utf-16BE — UTF-16 BE");
stringBuilder.AppendLine(" euc-kr — EUC-KR (한국어)");
stringBuilder.AppendLine(" ks_c_5601-1987 — 한글 완성형");
stringBuilder.AppendLine(" shift_jis — Shift-JIS (일본어)");
stringBuilder.AppendLine(" gb2312 — GB2312 (중국어 간체)");
stringBuilder.AppendLine(" iso-8859-1 — Latin-1 (서유럽)");
stringBuilder.AppendLine(" ascii — US-ASCII");
stringBuilder.AppendLine(" utf-32 — UTF-32");
return ToolResult.Ok(stringBuilder.ToString());
}
private static Encoding DetectEncodingFromBytes(byte[] bytes)
{
if (bytes.Length >= 3 && bytes[0] == 239 && bytes[1] == 187 && bytes[2] == 191)
{
return Encoding.UTF8;
}
if (bytes.Length >= 2 && bytes[0] == byte.MaxValue && bytes[1] == 254)
{
return Encoding.Unicode;
}
if (bytes.Length >= 2 && bytes[0] == 254 && bytes[1] == byte.MaxValue)
{
return Encoding.BigEndianUnicode;
}
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)
{
int num = 0;
bool flag = false;
while (num < bytes.Length)
{
if (bytes[num] <= 127)
{
num++;
continue;
}
int num2;
if ((bytes[num] & 0xE0) == 192)
{
num2 = 1;
}
else if ((bytes[num] & 0xF0) == 224)
{
num2 = 2;
}
else
{
if ((bytes[num] & 0xF8) != 240)
{
return false;
}
num2 = 3;
}
if (num + num2 >= bytes.Length)
{
return false;
}
for (int i = 1; i <= num2; i++)
{
if ((bytes[num + i] & 0xC0) != 128)
{
return false;
}
}
flag = true;
num += num2 + 1;
}
return flag || bytes.Length < 100;
}
private static bool HasBom(byte[] bytes)
{
if (bytes.Length >= 3 && bytes[0] == 239 && bytes[1] == 187 && bytes[2] == 191)
{
return true;
}
if (bytes.Length >= 2 && bytes[0] == byte.MaxValue && bytes[1] == 254)
{
return true;
}
if (bytes.Length >= 2 && bytes[0] == 254 && bytes[1] == byte.MaxValue)
{
return true;
}
return false;
}
}

View File

@@ -0,0 +1,155 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Action to perform"
};
int num = 4;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "get";
span[1] = "set";
span[2] = "list";
span[3] = "expand";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["name"] = new ToolProperty
{
Type = "string",
Description = "Variable name (for get/set)"
};
dictionary["value"] = new ToolProperty
{
Type = "string",
Description = "Variable value (for set) or string to expand (for expand)"
};
dictionary["filter"] = new ToolProperty
{
Type = "string",
Description = "Filter pattern for list action (case-insensitive substring match)"
};
toolParameterSchema.Properties = dictionary;
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "action";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string text = args.GetProperty("action").GetString() ?? "";
try
{
if (1 == 0)
{
}
ToolResult result = text switch
{
"get" => Get(args),
"set" => Set(args),
"list" => ListVars(args),
"expand" => Expand(args),
_ => ToolResult.Fail("Unknown action: " + text),
};
if (1 == 0)
{
}
return Task.FromResult(result);
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail("환경변수 오류: " + ex.Message));
}
}
private static ToolResult Get(JsonElement args)
{
if (!args.TryGetProperty("name", out var value))
{
return ToolResult.Fail("'name' parameter is required for get action");
}
string text = value.GetString() ?? "";
string environmentVariable = Environment.GetEnvironmentVariable(text);
return (environmentVariable != null) ? ToolResult.Ok(text + "=" + environmentVariable) : ToolResult.Ok(text + " is not set");
}
private static ToolResult Set(JsonElement args)
{
if (!args.TryGetProperty("name", out var value))
{
return ToolResult.Fail("'name' parameter is required for set action");
}
if (!args.TryGetProperty("value", out var value2))
{
return ToolResult.Fail("'value' parameter is required for set action");
}
string text = value.GetString() ?? "";
string value3 = value2.GetString() ?? "";
Environment.SetEnvironmentVariable(text, value3, EnvironmentVariableTarget.Process);
return ToolResult.Ok($"✓ Set {text}={value3} (process scope)");
}
private static ToolResult ListVars(JsonElement args)
{
JsonElement value2;
string value = (args.TryGetProperty("filter", out value2) ? (value2.GetString() ?? "") : "");
IDictionary environmentVariables = Environment.GetEnvironmentVariables();
List<string> list = new List<string>();
foreach (DictionaryEntry item in environmentVariables)
{
string text = item.Key?.ToString() ?? "";
string text2 = item.Value?.ToString() ?? "";
if (string.IsNullOrEmpty(value) || text.Contains(value, StringComparison.OrdinalIgnoreCase) || text2.Contains(value, StringComparison.OrdinalIgnoreCase))
{
if (text2.Length > 120)
{
text2 = text2.Substring(0, 120) + "...";
}
list.Add(text + "=" + text2);
}
}
list.Sort(StringComparer.OrdinalIgnoreCase);
string text3 = $"Environment variables ({list.Count}):\n" + string.Join("\n", list);
if (text3.Length > 8000)
{
text3 = text3.Substring(0, 8000) + "\n... (truncated)";
}
return ToolResult.Ok(text3);
}
private static ToolResult Expand(JsonElement args)
{
if (!args.TryGetProperty("value", out var value))
{
return ToolResult.Fail("'value' parameter is required for expand action");
}
string name = value.GetString() ?? "";
string output = Environment.ExpandEnvironmentVariables(name);
return ToolResult.Ok(output);
}
}

View File

@@ -0,0 +1,511 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "Output file path (.xlsx). Relative to work folder."
},
["sheet_name"] = new ToolProperty
{
Type = "string",
Description = "Sheet name. Default: 'Sheet1'."
},
["headers"] = new ToolProperty
{
Type = "array",
Description = "Column headers as JSON array of strings.",
Items = new ToolProperty
{
Type = "string"
}
},
["rows"] = new ToolProperty
{
Type = "array",
Description = "Data rows as JSON array of arrays. Use string starting with '=' for formulas (e.g. '=SUM(A2:A10)').",
Items = new ToolProperty
{
Type = "array",
Items = new ToolProperty
{
Type = "string"
}
}
},
["style"] = new ToolProperty
{
Type = "string",
Description = "Table style: 'styled' (blue header, striped rows, borders) or 'plain'. Default: 'styled'"
},
["col_widths"] = new ToolProperty
{
Type = "array",
Description = "Column widths as JSON array of numbers (in characters). e.g. [15, 10, 20]. Auto-fit if omitted.",
Items = new ToolProperty
{
Type = "number"
}
},
["freeze_header"] = new ToolProperty
{
Type = "boolean",
Description = "Freeze the header row. Default: true for styled."
},
["merges"] = new ToolProperty
{
Type = "array",
Description = "Cell merge ranges. e.g. [\"A1:C1\", \"D5:D8\"]",
Items = new ToolProperty
{
Type = "string"
}
},
["summary_row"] = new ToolProperty
{
Type = "object",
Description = "Auto-generate summary row. {\"label\": \"합계\", \"columns\": {\"B\": \"SUM\", \"C\": \"AVERAGE\"}}. Adds formulas at bottom."
}
}
};
int num = 3;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "path";
span[1] = "headers";
span[2] = "rows";
obj.Required = list;
return obj;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
JsonElement value = args.GetProperty("path");
string path = value.GetString() ?? "";
JsonElement sn;
string sheetName = (args.TryGetProperty("sheet_name", out sn) ? (sn.GetString() ?? "Sheet1") : "Sheet1");
JsonElement st;
string tableStyle = (args.TryGetProperty("style", out st) ? (st.GetString() ?? "styled") : "styled");
bool isStyled = tableStyle != "plain";
JsonElement fh;
bool freezeHeader = (args.TryGetProperty("freeze_header", out fh) ? fh.GetBoolean() : isStyled);
string 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
{
JsonElement headers = args.GetProperty("headers");
JsonElement rows = args.GetProperty("rows");
string dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
using SpreadsheetDocument spreadsheet = SpreadsheetDocument.Create(fullPath, SpreadsheetDocumentType.Workbook);
WorkbookPart workbookPart = spreadsheet.AddWorkbookPart();
workbookPart.Workbook = new Workbook();
WorkbookStylesPart stylesPart = workbookPart.AddNewPart<WorkbookStylesPart>();
stylesPart.Stylesheet = CreateStylesheet(isStyled);
stylesPart.Stylesheet.Save();
WorksheetPart worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
worksheetPart.Worksheet = new Worksheet();
int colCount = headers.GetArrayLength();
Columns columns = CreateColumns(args, colCount);
if (columns != null)
{
worksheetPart.Worksheet.Append(columns);
}
SheetData sheetData = new SheetData();
worksheetPart.Worksheet.Append(sheetData);
Sheets sheets = workbookPart.Workbook.AppendChild(new Sheets());
sheets.Append(new Sheet
{
Id = workbookPart.GetIdOfPart(worksheetPart),
SheetId = 1u,
Name = sheetName
});
Row headerRow = new Row
{
RowIndex = 1u
};
int colIdx = 0;
foreach (JsonElement h in headers.EnumerateArray())
{
string cellRef = GetCellReference(colIdx, 0);
Cell cell = new Cell
{
CellReference = cellRef,
DataType = CellValues.String,
CellValue = new CellValue(h.GetString() ?? ""),
StyleIndex = (isStyled ? 1u : 0u)
};
headerRow.Append(cell);
colIdx++;
}
sheetData.Append(headerRow);
int rowCount = 0;
uint rowNum = 2u;
foreach (JsonElement row in rows.EnumerateArray())
{
Row dataRow = new Row
{
RowIndex = rowNum
};
int ci = 0;
foreach (JsonElement cellVal in row.EnumerateArray())
{
string cellRef2 = GetCellReference(ci, (int)(rowNum - 1));
Cell cell2 = new Cell
{
CellReference = cellRef2
};
if (isStyled && rowCount % 2 == 0)
{
cell2.StyleIndex = 2u;
}
string strVal = cellVal.ToString();
if (strVal.StartsWith('='))
{
cell2.CellFormula = new CellFormula(strVal);
cell2.DataType = null;
}
else if (cellVal.ValueKind == JsonValueKind.Number)
{
cell2.DataType = CellValues.Number;
cell2.CellValue = new CellValue(cellVal.GetDouble().ToString());
}
else
{
cell2.DataType = CellValues.String;
cell2.CellValue = new CellValue(strVal);
}
dataRow.Append(cell2);
ci++;
}
sheetData.Append(dataRow);
rowCount++;
rowNum++;
}
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)
{
MergeCells mergeCells = new MergeCells();
foreach (JsonElement item in merges.EnumerateArray())
{
string range = item.GetString();
if (!string.IsNullOrEmpty(range))
{
mergeCells.Append(new MergeCell
{
Reference = range
});
}
}
if (mergeCells.HasChildren)
{
worksheetPart.Worksheet.InsertAfter(mergeCells, sheetData);
}
}
if (freezeHeader)
{
SheetViews sheetViews = new SheetViews(new SheetView(new Pane
{
VerticalSplit = 1.0,
TopLeftCell = "A2",
ActivePane = PaneValues.BottomLeft,
State = PaneStateValues.Frozen
}, new Selection
{
Pane = PaneValues.BottomLeft,
ActiveCell = "A2",
SequenceOfReferences = new ListValue<StringValue>
{
InnerText = "A2"
}
})
{
TabSelected = true,
WorkbookViewId = 0u
});
OpenXmlElement insertBefore = (OpenXmlElement)(((object)worksheetPart.Worksheet.GetFirstChild<Columns>()) ?? ((object)worksheetPart.Worksheet.GetFirstChild<SheetData>()));
worksheetPart.Worksheet.InsertBefore(sheetViews, insertBefore);
}
workbookPart.Workbook.Save();
List<string> features = new List<string>();
if (isStyled)
{
features.Add("스타일 적용");
}
if (freezeHeader)
{
features.Add("틀 고정");
}
if (args.TryGetProperty("merges", out value))
{
features.Add("셀 병합");
}
if (args.TryGetProperty("summary_row", out value))
{
features.Add("요약행");
}
string 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);
}
}
private static Stylesheet CreateStylesheet(bool isStyled)
{
Stylesheet stylesheet = new Stylesheet();
Fonts fonts = new Fonts(new Font(new FontSize
{
Val = 11.0
}, new FontName
{
Val = "맑은 고딕"
}), new Font(new Bold(), new FontSize
{
Val = 11.0
}, new Color
{
Rgb = "FFFFFFFF"
}, new FontName
{
Val = "맑은 고딕"
}), new Font(new Bold(), new FontSize
{
Val = 11.0
}, new FontName
{
Val = "맑은 고딕"
}));
stylesheet.Append(fonts);
Fills fills = new Fills(new Fill(new PatternFill
{
PatternType = PatternValues.None
}), new Fill(new PatternFill
{
PatternType = PatternValues.Gray125
}), new Fill(new PatternFill
{
PatternType = PatternValues.Solid,
ForegroundColor = new ForegroundColor
{
Rgb = "FF2E74B5"
},
BackgroundColor = new BackgroundColor
{
Indexed = 64u
}
}), new Fill(new PatternFill
{
PatternType = PatternValues.Solid,
ForegroundColor = new ForegroundColor
{
Rgb = "FFF2F7FB"
},
BackgroundColor = new BackgroundColor
{
Indexed = 64u
}
}), new Fill(new PatternFill
{
PatternType = PatternValues.Solid,
ForegroundColor = new ForegroundColor
{
Rgb = "FFE8E8E8"
},
BackgroundColor = new BackgroundColor
{
Indexed = 64u
}
}));
stylesheet.Append(fills);
Borders borders = new Borders(new Border(new LeftBorder(), new RightBorder(), new TopBorder(), new BottomBorder(), new DiagonalBorder()), new Border(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 cellFormats = new CellFormats(new CellFormat
{
FontId = 0u,
FillId = 0u,
BorderId = (isStyled ? 1u : 0u),
ApplyBorder = isStyled
}, new CellFormat
{
FontId = 1u,
FillId = 2u,
BorderId = 1u,
ApplyFont = true,
ApplyFill = true,
ApplyBorder = true,
Alignment = new Alignment
{
Horizontal = HorizontalAlignmentValues.Center,
Vertical = VerticalAlignmentValues.Center
}
}, new CellFormat
{
FontId = 0u,
FillId = 3u,
BorderId = 1u,
ApplyFill = true,
ApplyBorder = true
}, new CellFormat
{
FontId = 2u,
FillId = 4u,
BorderId = 1u,
ApplyFont = true,
ApplyFill = true,
ApplyBorder = true
});
stylesheet.Append(cellFormats);
return stylesheet;
}
private static Columns? CreateColumns(JsonElement args, int colCount)
{
JsonElement value;
bool flag = args.TryGetProperty("col_widths", out value) && value.ValueKind == JsonValueKind.Array;
Columns columns = new Columns();
for (int i = 0; i < colCount; i++)
{
double num = 15.0;
if (flag && i < value.GetArrayLength())
{
num = value[i].GetDouble();
}
columns.Append(new Column
{
Min = (uint)(i + 1),
Max = (uint)(i + 1),
Width = num,
CustomWidth = true
});
}
return columns;
}
private static void AddSummaryRow(SheetData sheetData, JsonElement summary, uint rowNum, int colCount, int dataRowCount, bool isStyled)
{
JsonElement value;
string text = (summary.TryGetProperty("label", out value) ? (value.GetString() ?? "합계") : "합계");
JsonElement value2;
JsonElement jsonElement = (summary.TryGetProperty("columns", out value2) ? value2 : default(JsonElement));
Row row = new Row
{
RowIndex = rowNum
};
Cell cell = new Cell
{
CellReference = GetCellReference(0, (int)(rowNum - 1)),
DataType = CellValues.String,
CellValue = new CellValue(text),
StyleIndex = (isStyled ? 3u : 0u)
};
row.Append(cell);
for (int i = 1; i < colCount; i++)
{
string columnLetter = GetColumnLetter(i);
Cell cell2 = new Cell
{
CellReference = GetCellReference(i, (int)(rowNum - 1)),
StyleIndex = (isStyled ? 3u : 0u)
};
if (jsonElement.ValueKind == JsonValueKind.Object && jsonElement.TryGetProperty(columnLetter, out var value3))
{
string value4 = value3.GetString()?.ToUpper() ?? "SUM";
int num = 2;
int value5 = num + dataRowCount - 1;
cell2.CellFormula = new CellFormula($"={value4}({columnLetter}{num}:{columnLetter}{value5})");
}
row.Append(cell2);
}
sheetData.Append(row);
}
private static string GetColumnLetter(int colIndex)
{
string text = "";
while (colIndex >= 0)
{
text = (char)(65 + colIndex % 26) + text;
colIndex = colIndex / 26 - 1;
}
return text;
}
private static string GetCellReference(int colIndex, int rowIndex)
{
return $"{GetColumnLetter(colIndex)}{rowIndex + 1}";
}
}

View File

@@ -0,0 +1,200 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "File path to edit"
},
["old_string"] = new ToolProperty
{
Type = "string",
Description = "Exact string to find and replace"
},
["new_string"] = new ToolProperty
{
Type = "string",
Description = "Replacement string"
},
["replace_all"] = new ToolProperty
{
Type = "boolean",
Description = "Replace all occurrences (default false). If false, old_string must be unique."
}
}
};
int num = 3;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "path";
span[1] = "old_string";
span[2] = "new_string";
obj.Required = list;
return obj;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string path = args.GetProperty("path").GetString() ?? "";
string oldStr = args.GetProperty("old_string").GetString() ?? "";
string newStr = args.GetProperty("new_string").GetString() ?? "";
JsonElement ra;
bool replaceAll = args.TryGetProperty("replace_all", out ra) && ra.GetBoolean();
string 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
{
string content = await File.ReadAllTextAsync(fullPath, Encoding.UTF8, ct);
int 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로 전체 교체하거나, 고유한 문자열을 지정하세요.");
}
string diffPreview = GenerateDiff(content, oldStr, newStr, fullPath);
string updated = content.Replace(oldStr, newStr);
await File.WriteAllTextAsync(fullPath, updated, Encoding.UTF8, ct);
string msg = ((replaceAll && count > 1) ? $"파일 수정 완료: {fullPath} ({count}곳 전체 교체)" : ("파일 수정 완료: " + fullPath));
return ToolResult.Ok(msg + "\n\n" + diffPreview, fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail("파일 수정 실패: " + ex.Message);
}
}
private static string GenerateDiff(string content, string oldStr, string newStr, string filePath)
{
string[] array = content.Split('\n');
int num = content.IndexOf(oldStr, StringComparison.Ordinal);
if (num < 0)
{
return "";
}
int num2 = content.Substring(0, num).Count((char c) => c == '\n');
string[] array2 = oldStr.Split('\n');
string[] array3 = newStr.Split('\n');
StringBuilder stringBuilder = new StringBuilder();
string fileName = Path.GetFileName(filePath);
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(13, 1, stringBuilder2);
handler.AppendLiteral("--- ");
handler.AppendFormatted(fileName);
handler.AppendLiteral(" (before)");
stringBuilder3.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(12, 1, stringBuilder2);
handler.AppendLiteral("+++ ");
handler.AppendFormatted(fileName);
handler.AppendLiteral(" (after)");
stringBuilder4.AppendLine(ref handler);
int num3 = Math.Max(0, num2 - 2);
int num4 = Math.Min(array.Length - 1, num2 + array2.Length - 1 + 2);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(8, 2, stringBuilder2);
handler.AppendLiteral("@@ -");
handler.AppendFormatted(num3 + 1);
handler.AppendLiteral(",");
handler.AppendFormatted(num4 - num3 + 1);
handler.AppendLiteral(" @@");
stringBuilder5.AppendLine(ref handler);
for (int num5 = num3; num5 < num2; num5++)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(1, 1, stringBuilder2);
handler.AppendLiteral(" ");
handler.AppendFormatted(array[num5].TrimEnd('\r'));
stringBuilder6.AppendLine(ref handler);
}
string[] array4 = array2;
foreach (string text in array4)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(1, 1, stringBuilder2);
handler.AppendLiteral("-");
handler.AppendFormatted(text.TrimEnd('\r'));
stringBuilder7.AppendLine(ref handler);
}
string[] array5 = array3;
foreach (string text2 in array5)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder8 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(1, 1, stringBuilder2);
handler.AppendLiteral("+");
handler.AppendFormatted(text2.TrimEnd('\r'));
stringBuilder8.AppendLine(ref handler);
}
int num8 = num2 + array2.Length;
for (int num9 = num8; num9 <= num4 && num9 < array.Length; num9++)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder9 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(1, 1, stringBuilder2);
handler.AppendLiteral(" ");
handler.AppendFormatted(array[num9].TrimEnd('\r'));
stringBuilder9.AppendLine(ref handler);
}
return stringBuilder.ToString().TrimEnd();
}
private static int CountOccurrences(string text, string search)
{
if (string.IsNullOrEmpty(search))
{
return 0;
}
int num = 0;
int startIndex = 0;
while ((startIndex = text.IndexOf(search, startIndex, StringComparison.Ordinal)) != -1)
{
num++;
startIndex += search.Length;
}
return num;
}
}

View File

@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty> { ["path"] = new ToolProperty
{
Type = "string",
Description = "File or directory path"
} }
};
int num = 1;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
CollectionsMarshal.AsSpan(list)[0] = "path";
obj.Required = list;
return obj;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string text = args.GetProperty("path").GetString() ?? "";
string text2 = (Path.IsPathRooted(text) ? text : Path.Combine(context.WorkFolder, text));
if (!context.IsPathAllowed(text2))
{
return Task.FromResult(ToolResult.Fail("경로 접근 차단: " + text2));
}
try
{
if (File.Exists(text2))
{
FileInfo fileInfo = new FileInfo(text2);
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("Type: File");
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(6, 1, stringBuilder2);
handler.AppendLiteral("Path: ");
handler.AppendFormatted(fileInfo.FullName);
stringBuilder3.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(15, 2, stringBuilder2);
handler.AppendLiteral("Size: ");
handler.AppendFormatted(FormatSize(fileInfo.Length));
handler.AppendLiteral(" (");
handler.AppendFormatted(fileInfo.Length, "N0");
handler.AppendLiteral(" bytes)");
stringBuilder4.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder2);
handler.AppendLiteral("Extension: ");
handler.AppendFormatted(fileInfo.Extension);
stringBuilder5.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("Created: ");
handler.AppendFormatted(fileInfo.CreationTime, "yyyy-MM-dd HH:mm:ss");
stringBuilder6.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(10, 1, stringBuilder2);
handler.AppendLiteral("Modified: ");
handler.AppendFormatted(fileInfo.LastWriteTime, "yyyy-MM-dd HH:mm:ss");
stringBuilder7.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder8 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(10, 1, stringBuilder2);
handler.AppendLiteral("ReadOnly: ");
handler.AppendFormatted(fileInfo.IsReadOnly);
stringBuilder8.AppendLine(ref handler);
HashSet<string> hashSet = 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 (hashSet.Contains(fileInfo.Extension) && fileInfo.Length < 52428800)
{
int num = File.ReadLines(text2).Take(1000000).Count();
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder9 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(7, 2, stringBuilder2);
handler.AppendLiteral("Lines: ");
handler.AppendFormatted(num, "N0");
handler.AppendFormatted((num >= 1000000) ? "+" : "");
stringBuilder9.AppendLine(ref handler);
}
return Task.FromResult(ToolResult.Ok(stringBuilder.ToString()));
}
if (Directory.Exists(text2))
{
DirectoryInfo directoryInfo = new DirectoryInfo(text2);
FileInfo[] files = directoryInfo.GetFiles("*", SearchOption.TopDirectoryOnly);
DirectoryInfo[] directories = directoryInfo.GetDirectories();
long bytes = files.Sum((FileInfo f) => f.Length);
StringBuilder stringBuilder10 = new StringBuilder();
stringBuilder10.AppendLine("Type: Directory");
StringBuilder stringBuilder2 = stringBuilder10;
StringBuilder stringBuilder11 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(6, 1, stringBuilder2);
handler.AppendLiteral("Path: ");
handler.AppendFormatted(directoryInfo.FullName);
stringBuilder11.AppendLine(ref handler);
stringBuilder2 = stringBuilder10;
StringBuilder stringBuilder12 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder2);
handler.AppendLiteral("Files: ");
handler.AppendFormatted(files.Length);
stringBuilder12.AppendLine(ref handler);
stringBuilder2 = stringBuilder10;
StringBuilder stringBuilder13 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(16, 1, stringBuilder2);
handler.AppendLiteral("Subdirectories: ");
handler.AppendFormatted(directories.Length);
stringBuilder13.AppendLine(ref handler);
stringBuilder2 = stringBuilder10;
StringBuilder stringBuilder14 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(30, 1, stringBuilder2);
handler.AppendLiteral("Total Size (top-level files): ");
handler.AppendFormatted(FormatSize(bytes));
stringBuilder14.AppendLine(ref handler);
stringBuilder2 = stringBuilder10;
StringBuilder stringBuilder15 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("Created: ");
handler.AppendFormatted(directoryInfo.CreationTime, "yyyy-MM-dd HH:mm:ss");
stringBuilder15.AppendLine(ref handler);
stringBuilder2 = stringBuilder10;
StringBuilder stringBuilder16 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(10, 1, stringBuilder2);
handler.AppendLiteral("Modified: ");
handler.AppendFormatted(directoryInfo.LastWriteTime, "yyyy-MM-dd HH:mm:ss");
stringBuilder16.AppendLine(ref handler);
return Task.FromResult(ToolResult.Ok(stringBuilder10.ToString()));
}
return Task.FromResult(ToolResult.Fail("경로를 찾을 수 없습니다: " + text2));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail("정보 조회 오류: " + ex.Message));
}
}
private static string FormatSize(long bytes)
{
if (1 == 0)
{
}
string result = ((bytes < 1048576) ? ((bytes >= 1024) ? $"{(double)bytes / 1024.0:F1}KB" : $"{bytes}B") : ((bytes >= 1073741824) ? $"{(double)bytes / 1073741824.0:F2}GB" : $"{(double)bytes / 1048576.0:F1}MB"));
if (1 == 0)
{
}
return result;
}
}

View File

@@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Action to perform"
};
int num = 5;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "move";
span[1] = "copy";
span[2] = "rename";
span[3] = "delete";
span[4] = "mkdir";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["path"] = new ToolProperty
{
Type = "string",
Description = "Source file/folder path"
};
dictionary["destination"] = new ToolProperty
{
Type = "string",
Description = "Destination path (for move/copy/rename)"
};
toolParameterSchema.Properties = dictionary;
num = 2;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
Span<string> span2 = CollectionsMarshal.AsSpan(list2);
span2[0] = "action";
span2[1] = "path";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string action = args.GetProperty("action").GetString() ?? "";
string rawPath = args.GetProperty("path").GetString() ?? "";
JsonElement d;
string dest = (args.TryGetProperty("destination", out d) ? (d.GetString() ?? "") : "");
string 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 && !(await context.AskPermission("file_manage(delete)", path)))
{
return ToolResult.Fail("사용자가 삭제를 거부했습니다.");
}
if (File.Exists(path))
{
File.Delete(path);
return ToolResult.Ok("파일 삭제: " + path, path);
}
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
return ToolResult.Ok("폴더 삭제: " + path, path);
}
return ToolResult.Fail("경로를 찾을 수 없습니다: " + path);
case "move":
case "copy":
case "rename":
{
if (string.IsNullOrEmpty(dest))
{
return ToolResult.Fail("'" + action + "' 작업에는 'destination'이 필요합니다.");
}
string destPath = (Path.IsPathRooted(dest) ? dest : Path.Combine(context.WorkFolder, dest));
if (!context.IsPathAllowed(destPath))
{
return ToolResult.Fail("대상 경로 접근 차단: " + destPath);
}
string destDir = Path.GetDirectoryName(destPath);
if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir))
{
Directory.CreateDirectory(destDir);
}
if (action == "rename")
{
string 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}", destPath);
}
if (Directory.Exists(path))
{
if (action == "copy")
{
CopyDirectory(path, destPath);
}
else
{
Directory.Move(path, destPath);
}
return ToolResult.Ok($"{action}: {path} → {destPath}", 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);
string[] files = Directory.GetFiles(src);
foreach (string text in files)
{
File.Copy(text, Path.Combine(dst, Path.GetFileName(text)), overwrite: true);
}
string[] directories = Directory.GetDirectories(src);
foreach (string text2 in directories)
{
CopyDirectory(text2, Path.Combine(dst, Path.GetFileName(text2)));
}
}
}

View File

@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "File path to read (absolute or relative to work folder)"
},
["offset"] = new ToolProperty
{
Type = "integer",
Description = "Starting line number (1-based). Optional, default 1."
},
["limit"] = new ToolProperty
{
Type = "integer",
Description = "Maximum number of lines to read. Optional, default 500."
}
}
};
int num = 1;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
CollectionsMarshal.AsSpan(list)[0] = "path";
obj.Required = list;
return obj;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("path", out var value))
{
return Task.FromResult(ToolResult.Fail("path가 필요합니다."));
}
string path = value.GetString() ?? "";
JsonElement value2;
int num = ((!args.TryGetProperty("offset", out value2)) ? 1 : value2.GetInt32());
JsonElement value3;
int num2 = (args.TryGetProperty("limit", out value3) ? value3.GetInt32() : 500);
string text = ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(text))
{
return Task.FromResult(ToolResult.Fail("경로 접근 차단: " + text));
}
if (!File.Exists(text))
{
return Task.FromResult(ToolResult.Fail("파일이 존재하지 않습니다: " + text));
}
try
{
string[] array = File.ReadAllLines(text, Encoding.UTF8);
int num3 = array.Length;
int num4 = Math.Max(0, num - 1);
int num5 = Math.Min(num3, num4 + num2);
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(22, 4, stringBuilder2);
handler.AppendLiteral("[");
handler.AppendFormatted(text);
handler.AppendLiteral("] (");
handler.AppendFormatted(num3);
handler.AppendLiteral(" lines, showing ");
handler.AppendFormatted(num4 + 1);
handler.AppendLiteral("-");
handler.AppendFormatted(num5);
handler.AppendLiteral(")");
stringBuilder3.AppendLine(ref handler);
for (int i = num4; i < num5; i++)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(1, 2, stringBuilder2);
handler.AppendFormatted(i + 1, 5);
handler.AppendLiteral("\t");
handler.AppendFormatted(array[i]);
stringBuilder4.AppendLine(ref handler);
}
return Task.FromResult(ToolResult.Ok(stringBuilder.ToString(), text));
}
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,272 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "Folder path to watch. Relative to work folder."
},
["pattern"] = new ToolProperty
{
Type = "string",
Description = "File pattern filter (e.g. '*.csv', '*.log', '*.xlsx'). Default: '*' (all files)"
},
["since"] = new ToolProperty
{
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 ToolProperty
{
Type = "boolean",
Description = "Search subdirectories recursively. Default: true"
},
["include_size"] = new ToolProperty
{
Type = "boolean",
Description = "Include file sizes in output. Default: true"
},
["top_n"] = new ToolProperty
{
Type = "integer",
Description = "Limit results to most recent N files. Default: 50"
}
}
};
int num = 1;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
CollectionsMarshal.AsSpan(list)[0] = "path";
obj.Required = list;
return obj;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string text = args.GetProperty("path").GetString() ?? "";
JsonElement value;
string text2 = (args.TryGetProperty("pattern", out value) ? (value.GetString() ?? "*") : "*");
JsonElement value2;
string text3 = (args.TryGetProperty("since", out value2) ? (value2.GetString() ?? "24h") : "24h");
JsonElement value3;
bool flag = !args.TryGetProperty("recursive", out value3) || value3.GetBoolean();
JsonElement value4;
bool includeSize = !args.TryGetProperty("include_size", out value4) || value4.GetBoolean();
JsonElement value5;
int value6;
int count = ((args.TryGetProperty("top_n", out value5) && value5.TryGetInt32(out value6)) ? value6 : 50);
string text4 = FileReadTool.ResolvePath(text, context.WorkFolder);
if (!context.IsPathAllowed(text4))
{
return Task.FromResult(ToolResult.Fail("경로 접근 차단: " + text4));
}
if (!Directory.Exists(text4))
{
return Task.FromResult(ToolResult.Fail("폴더 없음: " + text4));
}
try
{
DateTime since = ParseSince(text3);
SearchOption searchOption = (flag ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly);
List<FileInfo> list = (from f in Directory.GetFiles(text4, text2, searchOption)
select new FileInfo(f) into fi
where fi.LastWriteTime >= since || fi.CreationTime >= since
orderby fi.LastWriteTime descending
select fi).Take(count).ToList();
if (list.Count == 0)
{
return Task.FromResult(ToolResult.Ok($"\ud83d\udcc2 {text3} 이내 변경된 파일이 없습니다. (경로: {text}, 패턴: {text2})"));
}
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(23, 2, stringBuilder2);
handler.AppendLiteral("\ud83d\udcc2 파일 변경 감지: ");
handler.AppendFormatted(list.Count);
handler.AppendLiteral("개 파일 (");
handler.AppendFormatted(text3);
handler.AppendLiteral(" 이내)");
stringBuilder3.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(14, 2, stringBuilder2);
handler.AppendLiteral(" 경로: ");
handler.AppendFormatted(text);
handler.AppendLiteral(" | 패턴: ");
handler.AppendFormatted(text2);
stringBuilder4.AppendLine(ref handler);
stringBuilder.AppendLine();
List<FileInfo> list2 = list.Where((FileInfo f) => f.CreationTime >= since && f.CreationTime == f.LastWriteTime).ToList();
List<FileInfo> list3 = list.Where((FileInfo f) => f.LastWriteTime >= since && f.CreationTime < since).ToList();
List<FileInfo> list4 = list.Where((FileInfo f) => f.CreationTime >= since && f.CreationTime != f.LastWriteTime).ToList();
if (list2.Count > 0)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(13, 1, stringBuilder2);
handler.AppendLiteral("\ud83c\udd95 신규 생성 (");
handler.AppendFormatted(list2.Count);
handler.AppendLiteral("개):");
stringBuilder5.AppendLine(ref handler);
foreach (FileInfo item in list2)
{
AppendFileInfo(stringBuilder, item, text4, includeSize);
}
stringBuilder.AppendLine();
}
if (list3.Count > 0)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder2);
handler.AppendLiteral("✏\ufe0f 수정됨 (");
handler.AppendFormatted(list3.Count);
handler.AppendLiteral("개):");
stringBuilder6.AppendLine(ref handler);
foreach (FileInfo item2 in list3)
{
AppendFileInfo(stringBuilder, item2, text4, includeSize);
}
stringBuilder.AppendLine();
}
if (list4.Count > 0)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(16, 1, stringBuilder2);
handler.AppendLiteral("\ud83d\udcdd 생성 후 수정됨 (");
handler.AppendFormatted(list4.Count);
handler.AppendLiteral("개):");
stringBuilder7.AppendLine(ref handler);
foreach (FileInfo item3 in list4)
{
AppendFileInfo(stringBuilder, item3, text4, includeSize);
}
stringBuilder.AppendLine();
}
long bytes = list.Sum((FileInfo f) => f.Length);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder8 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(15, 2, stringBuilder2);
handler.AppendLiteral("── 요약: 총 ");
handler.AppendFormatted(list.Count);
handler.AppendLiteral("개 파일, ");
handler.AppendFormatted(FormatSize(bytes));
stringBuilder8.AppendLine(ref handler);
IEnumerable<IGrouping<string, FileInfo>> source = (from f in list
group f by f.Extension.ToLowerInvariant() into g
orderby g.Count() descending
select g).Take(10);
stringBuilder.Append(" 유형: ");
stringBuilder.AppendLine(string.Join(", ", source.Select((IGrouping<string, FileInfo> g) => $"{g.Key}({g.Count()})")));
return Task.FromResult(ToolResult.Ok(stringBuilder.ToString()));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail("파일 감시 실패: " + ex.Message));
}
}
private static DateTime ParseSince(string since)
{
if (DateTime.TryParse(since, out var result))
{
return result;
}
Match match = Regex.Match(since, "^(\\d+)(h|d|m)$");
if (match.Success)
{
int num = int.Parse(match.Groups[1].Value);
string value = match.Groups[2].Value;
if (1 == 0)
{
}
DateTime result2 = value switch
{
"h" => DateTime.Now.AddHours(-num),
"d" => DateTime.Now.AddDays(-num),
"m" => DateTime.Now.AddMinutes(-num),
_ => DateTime.Now.AddHours(-24.0),
};
if (1 == 0)
{
}
return result2;
}
return DateTime.Now.AddHours(-24.0);
}
private static void AppendFileInfo(StringBuilder sb, FileInfo f, string basePath, bool includeSize)
{
string relativePath = Path.GetRelativePath(basePath, f.FullName);
string value = f.LastWriteTime.ToString("MM-dd HH:mm");
if (includeSize)
{
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(8, 3, stringBuilder);
handler.AppendLiteral(" ");
handler.AppendFormatted(relativePath);
handler.AppendLiteral(" (");
handler.AppendFormatted(FormatSize(f.Length));
handler.AppendLiteral(", ");
handler.AppendFormatted(value);
handler.AppendLiteral(")");
stringBuilder2.AppendLine(ref handler);
}
else
{
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(6, 2, stringBuilder);
handler.AppendLiteral(" ");
handler.AppendFormatted(relativePath);
handler.AppendLiteral(" (");
handler.AppendFormatted(value);
handler.AppendLiteral(")");
stringBuilder3.AppendLine(ref handler);
}
}
private static string FormatSize(long bytes)
{
if (bytes >= 1024)
{
if (bytes >= 1048576)
{
if (bytes >= 1073741824)
{
return $"{(double)bytes / 1073741824.0:F2}GB";
}
return $"{(double)bytes / 1048576.0:F1}MB";
}
return $"{(double)bytes / 1024.0:F1}KB";
}
return $"{bytes}B";
}
}

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "File path to write (absolute or relative to work folder)"
},
["content"] = new ToolProperty
{
Type = "string",
Description = "Content to write to the file"
}
}
};
int num = 2;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "path";
span[1] = "content";
obj.Required = list;
return obj;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string path = args.GetProperty("path").GetString() ?? "";
string content = args.GetProperty("content").GetString() ?? "";
string 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
{
string dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8, ct);
int 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,220 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public class FolderMapTool : IAgentTool
{
private static readonly HashSet<string> IgnoredDirs = new HashSet<string>(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 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 ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "Subdirectory to map. Optional, defaults to work folder root."
},
["depth"] = new ToolProperty
{
Type = "integer",
Description = "Maximum depth to traverse (1-10). Default: 3."
},
["include_files"] = new ToolProperty
{
Type = "boolean",
Description = "Whether to include files. Default: true."
},
["pattern"] = new ToolProperty
{
Type = "string",
Description = "File extension filter (e.g. '.cs', '.py'). Optional, shows all files if omitted."
}
},
Required = new List<string>()
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
JsonElement value;
string text = (args.TryGetProperty("path", out value) ? (value.GetString() ?? "") : "");
int num = 3;
if (args.TryGetProperty("depth", out var value2))
{
int result;
if (value2.ValueKind == JsonValueKind.Number)
{
num = value2.GetInt32();
}
else if (value2.ValueKind == JsonValueKind.String && int.TryParse(value2.GetString(), out result))
{
num = result;
}
}
string s = num.ToString();
bool includeFiles = true;
if (args.TryGetProperty("include_files", out var value3))
{
includeFiles = ((value3.ValueKind != JsonValueKind.True && value3.ValueKind != JsonValueKind.False) ? (!string.Equals(value3.GetString(), "false", StringComparison.OrdinalIgnoreCase)) : value3.GetBoolean());
}
JsonElement value4;
string extFilter = (args.TryGetProperty("pattern", out value4) ? (value4.GetString() ?? "") : "");
if (!int.TryParse(s, out var result2) || result2 < 1)
{
result2 = 3;
}
result2 = Math.Min(result2, 10);
string text2 = (string.IsNullOrEmpty(text) ? context.WorkFolder : FileReadTool.ResolvePath(text, context.WorkFolder));
if (string.IsNullOrEmpty(text2) || !Directory.Exists(text2))
{
return Task.FromResult(ToolResult.Fail("디렉토리가 존재하지 않습니다: " + text2));
}
if (!context.IsPathAllowed(text2))
{
return Task.FromResult(ToolResult.Fail("경로 접근 차단: " + text2));
}
try
{
StringBuilder stringBuilder = new StringBuilder();
string value5 = Path.GetFileName(text2);
if (string.IsNullOrEmpty(value5))
{
value5 = text2;
}
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(1, 1, stringBuilder2);
handler.AppendFormatted(value5);
handler.AppendLiteral("/");
stringBuilder3.AppendLine(ref handler);
int entryCount = 0;
BuildTree(stringBuilder, text2, "", 0, result2, includeFiles, extFilter, context, ref entryCount);
if (entryCount >= 500)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(42, 1, stringBuilder2);
handler.AppendLiteral("\n... (");
handler.AppendFormatted(500);
handler.AppendLiteral("개 항목 제한 도달, depth 또는 pattern을 조정하세요)");
stringBuilder4.AppendLine(ref handler);
}
string value6 = $"폴더 맵 생성 완료 ({entryCount}개 항목, 깊이 {result2})";
return Task.FromResult(ToolResult.Ok($"{value6}\n\n{stringBuilder}"));
}
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 >= 500)
{
return;
}
List<DirectoryInfo> list;
try
{
list = (from d in new DirectoryInfo(dir).GetDirectories()
where !d.Attributes.HasFlag(FileAttributes.Hidden) && !IgnoredDirs.Contains(d.Name)
orderby d.Name
select d).ToList();
}
catch
{
return;
}
List<FileInfo> list2 = new List<FileInfo>();
if (includeFiles)
{
try
{
list2 = (from f in new DirectoryInfo(dir).GetFiles()
where !f.Attributes.HasFlag(FileAttributes.Hidden) && (string.IsNullOrEmpty(extFilter) || f.Extension.Equals(extFilter, StringComparison.OrdinalIgnoreCase))
orderby f.Name
select f).ToList();
}
catch
{
}
}
int num = list.Count + list2.Count;
int num2 = 0;
foreach (DirectoryInfo item in list)
{
if (entryCount >= 500)
{
break;
}
num2++;
bool flag = num2 == num;
string value = (flag ? "└── " : "├── ");
string text = (flag ? " " : "│ ");
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(1, 3, stringBuilder);
handler.AppendFormatted(prefix);
handler.AppendFormatted(value);
handler.AppendFormatted(item.Name);
handler.AppendLiteral("/");
stringBuilder2.AppendLine(ref handler);
entryCount++;
if (context.IsPathAllowed(item.FullName))
{
BuildTree(sb, item.FullName, prefix + text, currentDepth + 1, maxDepth, includeFiles, extFilter, context, ref entryCount);
}
}
foreach (FileInfo item2 in list2)
{
if (entryCount >= 500)
{
break;
}
num2++;
string value2 = ((num2 == num) ? "└── " : "├── ");
string value3 = FormatSize(item2.Length);
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(4, 4, stringBuilder);
handler.AppendFormatted(prefix);
handler.AppendFormatted(value2);
handler.AppendFormatted(item2.Name);
handler.AppendLiteral(" (");
handler.AppendFormatted(value3);
handler.AppendLiteral(")");
stringBuilder3.AppendLine(ref handler);
entryCount++;
}
}
private static string FormatSize(long bytes)
{
if (1 == 0)
{
}
string result = ((bytes < 1024) ? $"{bytes} B" : ((bytes >= 1048576) ? $"{(double)bytes / 1048576.0:F1} MB" : $"{(double)bytes / 1024.0:F1} KB"));
if (1 == 0)
{
}
return result;
}
}

View File

@@ -0,0 +1,249 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Markdig;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["source"] = new ToolProperty
{
Type = "string",
Description = "Source file path to convert"
},
["target"] = new ToolProperty
{
Type = "string",
Description = "Target output file path (extension determines format)"
},
["mood"] = new ToolProperty
{
Type = "string",
Description = "Design mood for HTML output (default: modern). Only used for md→html conversion."
}
}
};
int num = 2;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "source";
span[1] = "target";
obj.Required = list;
return obj;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string source = args.GetProperty("source").GetString() ?? "";
string target = args.GetProperty("target").GetString() ?? "";
JsonElement m;
string mood = (args.TryGetProperty("mood", out m) ? (m.GetString() ?? "modern") : "modern");
string srcPath = FileReadTool.ResolvePath(source, context.WorkFolder);
string 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("쓰기 권한이 거부되었습니다.");
}
string srcExt = Path.GetExtension(srcPath).ToLowerInvariant();
string tgtExt = Path.GetExtension(tgtPath).ToLowerInvariant();
string convKey = srcExt + "→" + tgtExt;
try
{
string srcContent = await File.ReadAllTextAsync(srcPath, ct);
string result;
switch (convKey)
{
case ".md→.html":
{
MarkdownPipeline pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
string bodyHtml = Markdown.ToHtml(srcContent, pipeline);
string 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":
case ".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 등)을 사용하세요.");
}
string dir = Path.GetDirectoryName(tgtPath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
await File.WriteAllTextAsync(tgtPath, result, ct);
string srcName = Path.GetFileName(srcPath);
string 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)
{
string input = Regex.Replace(html, "<(script|style)[^>]*>.*?</\\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
input = Regex.Replace(input, "<br\\s*/?>", "\n", RegexOptions.IgnoreCase);
input = Regex.Replace(input, "</(p|div|h[1-6]|li|tr)>", "\n", RegexOptions.IgnoreCase);
input = Regex.Replace(input, "<[^>]+>", "");
input = WebUtility.HtmlDecode(input);
input = Regex.Replace(input, "\\n{3,}", "\n\n");
return input.Trim();
}
private static string StripMarkdown(string md)
{
string input = md;
input = Regex.Replace(input, "^#{1,6}\\s+", "", RegexOptions.Multiline);
input = Regex.Replace(input, "\\*\\*(.+?)\\*\\*", "$1");
input = Regex.Replace(input, "\\*(.+?)\\*", "$1");
input = Regex.Replace(input, "`(.+?)`", "$1");
input = Regex.Replace(input, "^\\s*[-*+]\\s+", "", RegexOptions.Multiline);
input = Regex.Replace(input, "^\\s*\\d+\\.\\s+", "", RegexOptions.Multiline);
input = Regex.Replace(input, "\\[(.+?)\\]\\(.+?\\)", "$1");
return input.Trim();
}
private static string CsvToHtmlTable(string csv, string mood)
{
string[] array = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (array.Length == 0)
{
return "<p>빈 CSV 파일</p>";
}
string css = TemplateService.GetCss(mood);
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("<!DOCTYPE html>\n<html lang=\"ko\">\n<head>\n<meta charset=\"UTF-8\"/>");
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(54, 1, stringBuilder2);
handler.AppendLiteral("<style>");
handler.AppendFormatted(css);
handler.AppendLiteral("</style>\n</head>\n<body>\n<div class=\"container\">");
stringBuilder3.AppendLine(ref handler);
stringBuilder.AppendLine("<table><thead><tr>");
string[] array2 = ParseCsvLine(array[0]);
string[] array3 = array2;
foreach (string value in array3)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("<th>");
handler.AppendFormatted(WebUtility.HtmlEncode(value));
handler.AppendLiteral("</th>");
stringBuilder4.Append(ref handler);
}
stringBuilder.AppendLine("</tr></thead><tbody>");
for (int j = 1; j < Math.Min(array.Length, 1001); j++)
{
string[] array4 = ParseCsvLine(array[j]);
stringBuilder.Append("<tr>");
string[] array5 = array4;
foreach (string value2 in array5)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("<td>");
handler.AppendFormatted(WebUtility.HtmlEncode(value2));
handler.AppendLiteral("</td>");
stringBuilder5.Append(ref handler);
}
stringBuilder.AppendLine("</tr>");
}
stringBuilder.AppendLine("</tbody></table>\n</div>\n</body>\n</html>");
return stringBuilder.ToString();
}
private static string[] ParseCsvLine(string line)
{
List<string> list = new List<string>();
StringBuilder stringBuilder = new StringBuilder();
bool flag = false;
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (flag)
{
if (c == '"' && i + 1 < line.Length && line[i + 1] == '"')
{
stringBuilder.Append('"');
i++;
}
else if (c == '"')
{
flag = false;
}
else
{
stringBuilder.Append(c);
}
continue;
}
switch (c)
{
case '"':
flag = true;
break;
case ',':
list.Add(stringBuilder.ToString());
stringBuilder.Clear();
break;
default:
stringBuilder.Append(c);
break;
}
}
list.Add(stringBuilder.ToString());
return list.ToArray();
}
}

View File

@@ -0,0 +1,242 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public class GitTool : IAgentTool
{
private static readonly string[] BlockedPatterns = new string[16]
{
"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 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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Git action: status, diff, log, add, commit, branch, checkout, stash, remote"
};
int num = 9;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "status";
span[1] = "diff";
span[2] = "log";
span[3] = "add";
span[4] = "commit";
span[5] = "branch";
span[6] = "checkout";
span[7] = "stash";
span[8] = "remote";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["args"] = new ToolProperty
{
Type = "string",
Description = "Additional arguments. For commit: commit message. For add: file path(s). For log: '--oneline -10'. For diff: file path or '--staged'."
};
toolParameterSchema.Properties = dictionary;
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "action";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("action", out var actionEl))
{
return ToolResult.Fail("action이 필요합니다.");
}
string action = actionEl.GetString() ?? "status";
JsonElement a;
string extraArgs = (args.TryGetProperty("args", out a) ? (a.GetString() ?? "") : "");
string workDir = context.WorkFolder;
if (string.IsNullOrEmpty(workDir) || !Directory.Exists(workDir))
{
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
}
string gitPath = FindGit();
if (gitPath == null)
{
return ToolResult.Fail("Git이 설치되어 있지 않습니다. PATH에 git이 있는지 확인하세요.");
}
if (!Directory.Exists(Path.Combine(workDir, ".git")))
{
string checkDir = workDir;
bool found = false;
while (!string.IsNullOrEmpty(checkDir))
{
if (Directory.Exists(Path.Combine(checkDir, ".git")))
{
found = true;
break;
}
string parent = Directory.GetParent(checkDir)?.FullName;
if (parent == checkDir)
{
break;
}
checkDir = parent;
}
if (!found)
{
return ToolResult.Fail("현재 작업 폴더는 Git 저장소가 아닙니다.");
}
}
if (1 == 0)
{
}
string text = 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 (1 == 0)
{
}
string gitCommand = text;
if (gitCommand == null)
{
if (action == "commit")
{
return ToolResult.Fail("커밋 메시지가 필요합니다. args에 커밋 메시지를 지정하세요.");
}
if (action == "checkout")
{
return ToolResult.Fail("체크아웃할 브랜치/파일을 args에 지정하세요.");
}
return ToolResult.Fail("알 수 없는 액션: " + action);
}
string fullCmd = "git " + gitCommand;
string[] blockedPatterns = BlockedPatterns;
foreach (string pattern in blockedPatterns)
{
if (fullCmd.Contains(pattern, StringComparison.OrdinalIgnoreCase))
{
return ToolResult.Fail("안전을 위해 '" + pattern + "' 작업은 차단됩니다.\n원격 저장소 작업(push/pull/fetch)과 이력 변경 작업은 사용자가 직접 수행하세요.");
}
}
HashSet<string> writeActions = new HashSet<string> { "add", "commit", "checkout", "stash" };
if (writeActions.Contains(action) && !(await context.CheckWritePermissionAsync(Name, workDir)))
{
return ToolResult.Fail("Git 쓰기 권한이 거부되었습니다.");
}
if (action == "commit")
{
return ToolResult.Fail("Git 커밋 기능은 현재 비활성 상태입니다.\n안전을 위해 커밋은 사용자가 직접 수행하세요.\n향후 버전에서 활성화될 예정입니다.");
}
try
{
ProcessStartInfo psi = new ProcessStartInfo(gitPath, gitCommand)
{
WorkingDirectory = workDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(30.0));
using Process proc = Process.Start(psi);
if (proc == null)
{
return ToolResult.Fail("Git 프로세스 시작 실패");
}
string stdout = await proc.StandardOutput.ReadToEndAsync(cts.Token);
string stderr = await proc.StandardError.ReadToEndAsync(cts.Token);
await proc.WaitForExitAsync(cts.Token);
if (stdout.Length > 8000)
{
stdout = stdout.Substring(0, 8000) + "\n... (출력 잘림)";
}
StringBuilder sb = new StringBuilder();
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(18, 2, stringBuilder);
handler.AppendLiteral("[git ");
handler.AppendFormatted(action);
handler.AppendLiteral("] Exit code: ");
handler.AppendFormatted(proc.ExitCode);
stringBuilder2.AppendLine(ref handler);
if (!string.IsNullOrWhiteSpace(stdout))
{
sb.Append(stdout);
}
if (!string.IsNullOrWhiteSpace(stderr) && proc.ExitCode != 0)
{
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(10, 1, stringBuilder);
handler.AppendLiteral("\n[stderr] ");
handler.AppendFormatted(stderr.Trim());
stringBuilder3.AppendLine(ref handler);
}
return (proc.ExitCode == 0) ? ToolResult.Ok(sb.ToString()) : ToolResult.Fail(sb.ToString());
}
catch (OperationCanceledException)
{
return ToolResult.Fail("Git 명령 타임아웃 (30초)");
}
catch (Exception ex2)
{
return ToolResult.Fail("Git 실행 오류: " + ex2.Message);
}
}
private static string? FindGit()
{
try
{
ProcessStartInfo startInfo = new ProcessStartInfo("where.exe", "git")
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process == null)
{
return null;
}
string text = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(5000);
return string.IsNullOrWhiteSpace(text) ? null : text.Split('\n')[0].Trim();
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public class GlobTool : IAgentTool
{
public string Name => "glob";
public string Description => "Find files matching a glob pattern (e.g. '**/*.cs', 'src/**/*.json'). Returns matching file paths.";
public ToolParameterSchema Parameters
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["pattern"] = new ToolProperty
{
Type = "string",
Description = "Glob pattern to match files (e.g. '**/*.cs', '*.txt')"
},
["path"] = new ToolProperty
{
Type = "string",
Description = "Directory to search in. Optional, defaults to work folder."
}
}
};
int num = 1;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
CollectionsMarshal.AsSpan(list)[0] = "pattern";
obj.Required = list;
return obj;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string text = args.GetProperty("pattern").GetString() ?? "";
JsonElement value;
string text2 = (args.TryGetProperty("path", out value) ? (value.GetString() ?? "") : "");
string baseDir = (string.IsNullOrEmpty(text2) ? context.WorkFolder : FileReadTool.ResolvePath(text2, 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
{
string searchPattern = ExtractSearchPattern(text);
SearchOption searchOption = ((text.Contains("**") || text.Contains('/') || text.Contains('\\')) ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly);
List<string> list = (from f in Directory.EnumerateFiles(baseDir, searchPattern, searchOption)
where context.IsPathAllowed(f)
orderby f
select f).Take(200).ToList();
if (list.Count == 0)
{
return Task.FromResult(ToolResult.Ok("패턴 '" + text + "'에 일치하는 파일이 없습니다."));
}
string value2 = string.Join("\n", list.Select((string f) => Path.GetRelativePath(baseDir, f)));
return Task.FromResult(ToolResult.Ok($"{list.Count}개 파일 발견:\n{value2}"));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail("검색 실패: " + ex.Message));
}
}
private static string ExtractSearchPattern(string globPattern)
{
string text = globPattern.Replace('/', '\\').Split('\\')[^1];
return (string.IsNullOrEmpty(text) || text == "**") ? "*" : text;
}
}

View File

@@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["pattern"] = new ToolProperty
{
Type = "string",
Description = "Search pattern (regex supported)"
},
["path"] = new ToolProperty
{
Type = "string",
Description = "File or directory to search in. Optional, defaults to work folder."
},
["glob"] = new ToolProperty
{
Type = "string",
Description = "File pattern filter (e.g. '*.cs', '*.json'). Optional."
},
["context_lines"] = new ToolProperty
{
Type = "integer",
Description = "Number of context lines before/after each match (0-5). Default 0."
},
["case_sensitive"] = new ToolProperty
{
Type = "boolean",
Description = "Case-sensitive search. Default false (case-insensitive)."
}
}
};
int num = 1;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
CollectionsMarshal.AsSpan(list)[0] = "pattern";
obj.Required = list;
return obj;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string text = args.GetProperty("pattern").GetString() ?? "";
JsonElement value;
string text2 = (args.TryGetProperty("path", out value) ? (value.GetString() ?? "") : "");
JsonElement value2;
string text3 = (args.TryGetProperty("glob", out value2) ? (value2.GetString() ?? "") : "");
JsonElement value3;
int num = (args.TryGetProperty("context_lines", out value3) ? Math.Clamp(value3.GetInt32(), 0, 5) : 0);
JsonElement value4;
bool flag = args.TryGetProperty("case_sensitive", out value4) && value4.GetBoolean();
string text4 = (string.IsNullOrEmpty(text2) ? context.WorkFolder : FileReadTool.ResolvePath(text2, context.WorkFolder));
if (string.IsNullOrEmpty(text4))
{
return Task.FromResult(ToolResult.Fail("작업 폴더가 설정되지 않았습니다."));
}
try
{
RegexOptions options = (RegexOptions)(8 | ((!flag) ? 1 : 0));
Regex regex = new Regex(text, options, TimeSpan.FromSeconds(5.0));
string searchPattern = (string.IsNullOrEmpty(text3) ? "*" : text3);
IEnumerable<string> enumerable;
if (File.Exists(text4))
{
enumerable = new _003C_003Ez__ReadOnlySingleElementList<string>(text4);
}
else
{
if (!Directory.Exists(text4))
{
return Task.FromResult(ToolResult.Fail("경로가 존재하지 않습니다: " + text4));
}
enumerable = Directory.EnumerateFiles(text4, searchPattern, SearchOption.AllDirectories);
}
StringBuilder stringBuilder = new StringBuilder();
int num2 = 0;
int num3 = 0;
foreach (string item in enumerable)
{
if (ct.IsCancellationRequested)
{
break;
}
if (!context.IsPathAllowed(item) || IsBinaryFile(item))
{
continue;
}
try
{
string[] array = File.ReadAllLines(item, Encoding.UTF8);
bool flag2 = false;
for (int i = 0; i < array.Length; i++)
{
if (num2 >= 100)
{
break;
}
if (!regex.IsMatch(array[i]))
{
continue;
}
StringBuilder stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler;
if (!flag2)
{
string value5 = (Directory.Exists(context.WorkFolder) ? Path.GetRelativePath(context.WorkFolder, item) : item);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(2, 1, stringBuilder2);
handler.AppendLiteral("\n");
handler.AppendFormatted(value5);
handler.AppendLiteral(":");
stringBuilder3.AppendLine(ref handler);
flag2 = true;
num3++;
}
if (num > 0)
{
for (int j = Math.Max(0, i - num); j < i; j++)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 2, stringBuilder2);
handler.AppendLiteral(" ");
handler.AppendFormatted(j + 1);
handler.AppendLiteral(" ");
handler.AppendFormatted(array[j].TrimEnd());
stringBuilder4.AppendLine(ref handler);
}
}
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 2, stringBuilder2);
handler.AppendLiteral(" ");
handler.AppendFormatted(i + 1);
handler.AppendLiteral(": ");
handler.AppendFormatted(array[i].TrimEnd());
stringBuilder5.AppendLine(ref handler);
if (num > 0)
{
for (int k = i + 1; k <= Math.Min(array.Length - 1, i + num); k++)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 2, stringBuilder2);
handler.AppendLiteral(" ");
handler.AppendFormatted(k + 1);
handler.AppendLiteral(" ");
handler.AppendFormatted(array[k].TrimEnd());
stringBuilder6.AppendLine(ref handler);
}
stringBuilder.AppendLine(" ---");
}
num2++;
}
}
catch
{
}
if (num2 < 100)
{
continue;
}
break;
}
if (num2 == 0)
{
return Task.FromResult(ToolResult.Ok("패턴 '" + text + "'에 일치하는 결과가 없습니다."));
}
string text5 = $"{num3}개 파일에서 {num2}개 일치{((num2 >= 100) ? " ( )" : "")}:";
return Task.FromResult(ToolResult.Ok(text5 + stringBuilder));
}
catch (RegexParseException)
{
return Task.FromResult(ToolResult.Fail("잘못된 정규식 패턴: " + text));
}
catch (Exception ex2)
{
return Task.FromResult(ToolResult.Fail("검색 실패: " + ex2.Message));
}
}
private static bool IsBinaryFile(string path)
{
switch (Path.GetExtension(path).ToLowerInvariant())
{
case ".exe":
case ".dll":
case ".zip":
case ".7z":
case ".rar":
case ".tar":
case ".gz":
case ".png":
case ".jpg":
case ".jpeg":
case ".gif":
case ".bmp":
case ".ico":
case ".webp":
case ".pdf":
case ".docx":
case ".xlsx":
case ".pptx":
case ".mp3":
case ".mp4":
case ".avi":
case ".mov":
case ".mkv":
case ".psd":
case ".msi":
case ".iso":
case ".bin":
case ".dat":
case ".db":
return true;
default:
return false;
}
}
}

View File

@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Input mode"
};
int num = 2;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "text";
span[1] = "file";
obj.Enum = list;
dictionary["mode"] = obj;
dictionary["input"] = new ToolProperty
{
Type = "string",
Description = "Text to hash or file path"
};
ToolProperty obj2 = new ToolProperty
{
Type = "string",
Description = "Hash algorithm (default: sha256)"
};
num = 4;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
Span<string> span2 = CollectionsMarshal.AsSpan(list2);
span2[0] = "md5";
span2[1] = "sha1";
span2[2] = "sha256";
span2[3] = "sha512";
obj2.Enum = list2;
dictionary["algorithm"] = obj2;
toolParameterSchema.Properties = dictionary;
num = 2;
List<string> list3 = new List<string>(num);
CollectionsMarshal.SetCount(list3, num);
Span<string> span3 = CollectionsMarshal.AsSpan(list3);
span3[0] = "mode";
span3[1] = "input";
toolParameterSchema.Required = list3;
return toolParameterSchema;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string text = args.GetProperty("mode").GetString() ?? "text";
string text2 = args.GetProperty("input").GetString() ?? "";
JsonElement value;
string text3 = (args.TryGetProperty("algorithm", out value) ? (value.GetString() ?? "sha256") : "sha256");
try
{
byte[] array;
string value2;
if (text == "file")
{
string text4 = (Path.IsPathRooted(text2) ? text2 : Path.Combine(context.WorkFolder, text2));
if (!File.Exists(text4))
{
return Task.FromResult(ToolResult.Fail("File not found: " + text4));
}
array = File.ReadAllBytes(text4);
value2 = Path.GetFileName(text4);
}
else
{
array = Encoding.UTF8.GetBytes(text2);
value2 = $"text ({array.Length} bytes)";
}
if (1 == 0)
{
}
HashAlgorithm hashAlgorithm = text3 switch
{
"md5" => MD5.Create(),
"sha1" => SHA1.Create(),
"sha256" => SHA256.Create(),
"sha512" => SHA512.Create(),
_ => SHA256.Create(),
};
if (1 == 0)
{
}
using HashAlgorithm hashAlgorithm2 = hashAlgorithm;
byte[] array2 = hashAlgorithm2.ComputeHash(array);
string value3 = BitConverter.ToString(array2).Replace("-", "").ToLowerInvariant();
return Task.FromResult(ToolResult.Ok($"{text3.ToUpperInvariant()}({value2}):\n{value3}"));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail("해시 오류: " + ex.Message));
}
}
}

View File

@@ -0,0 +1,3 @@
namespace AxCopilot.Services.Agent;
public record HookExecutionResult(string HookName, bool Success, string Output);

View File

@@ -0,0 +1,336 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "Output file path (.html). Relative to work folder."
},
["title"] = new ToolProperty
{
Type = "string",
Description = "Document title (shown in browser tab and header)"
},
["body"] = new ToolProperty
{
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 ToolProperty
{
Type = "string",
Description = "Design template mood: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard. Default: modern"
},
["style"] = new ToolProperty
{
Type = "string",
Description = "Optional additional CSS. Appended after mood+shared CSS."
},
["toc"] = new ToolProperty
{
Type = "boolean",
Description = "Auto-generate table of contents from h2/h3 headings. Default: false"
},
["numbered"] = new ToolProperty
{
Type = "boolean",
Description = "Auto-number h2/h3 sections (1., 1-1., etc). Default: false"
},
["cover"] = new ToolProperty
{
Type = "object",
Description = "Cover page config: {\"title\": \"...\", \"subtitle\": \"...\", \"author\": \"...\", \"date\": \"...\", \"gradient\": \"#hex1,#hex2\"}. Omit to skip cover page."
}
}
};
int num = 3;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "path";
span[1] = "title";
span[2] = "body";
obj.Required = list;
return obj;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string path = args.GetProperty("path").GetString() ?? "";
string title = args.GetProperty("title").GetString() ?? "Report";
string body = args.GetProperty("body").GetString() ?? "";
JsonElement s;
string customStyle = (args.TryGetProperty("style", out s) ? s.GetString() : null);
JsonElement m;
string mood = (args.TryGetProperty("mood", out m) ? (m.GetString() ?? "modern") : "modern");
JsonElement tocVal;
bool useToc = args.TryGetProperty("toc", out tocVal) && tocVal.GetBoolean();
JsonElement numVal;
bool useNumbered = args.TryGetProperty("numbered", out numVal) && numVal.GetBoolean();
JsonElement coverVal;
bool hasCover = args.TryGetProperty("cover", out coverVal) && coverVal.ValueKind == JsonValueKind.Object;
string 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
{
string dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
string style = TemplateService.GetCss(mood);
if (!string.IsNullOrEmpty(customStyle))
{
style = style + "\n" + customStyle;
}
TemplateMood moodInfo = TemplateService.GetMood(mood);
string moodLabel = ((moodInfo != null) ? (" · " + moodInfo.Icon + " " + moodInfo.Label) : "");
if (useNumbered)
{
body = AddNumberedClass(body);
}
body = EnsureHeadingIds(body);
string tocHtml = (useToc ? GenerateToc(body) : "");
string coverHtml = (hasCover ? GenerateCover(coverVal, title) : "");
StringBuilder 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\">");
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(15, 1, stringBuilder);
handler.AppendLiteral("<title>");
handler.AppendFormatted(Escape(title));
handler.AppendLiteral("</title>");
stringBuilder2.AppendLine(ref handler);
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(15, 1, stringBuilder);
handler.AppendLiteral("<style>");
handler.AppendFormatted(style);
handler.AppendLiteral("</style>");
stringBuilder3.AppendLine(ref handler);
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.AppendLine("<div class=\"container\">");
if (!string.IsNullOrEmpty(coverHtml))
{
sb.AppendLine(coverHtml);
}
else
{
stringBuilder = sb;
StringBuilder stringBuilder4 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder);
handler.AppendLiteral("<h1>");
handler.AppendFormatted(Escape(title));
handler.AppendLiteral("</h1>");
stringBuilder4.AppendLine(ref handler);
stringBuilder = sb;
StringBuilder stringBuilder5 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(41, 2, stringBuilder);
handler.AppendLiteral("<div class=\"meta\">생성: ");
handler.AppendFormatted(DateTime.Now, "yyyy-MM-dd HH:mm");
handler.AppendLiteral(" | AX Copilot");
handler.AppendFormatted(moodLabel);
handler.AppendLiteral("</div>");
stringBuilder5.AppendLine(ref handler);
}
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);
List<string> features = new List<string>();
if (useToc)
{
features.Add("목차");
}
if (useNumbered)
{
features.Add("섹션번호");
}
if (hasCover)
{
features.Add("커버페이지");
}
string featureStr = ((features.Count > 0) ? (" [" + string.Join(", ", features) + "]") : "");
return ToolResult.Ok($"HTML 문서 생성 완료: {fullPath} (디자인: {mood}{featureStr})", fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail("HTML 생성 실패: " + ex.Message);
}
}
private static string EnsureHeadingIds(string html)
{
int counter = 0;
return Regex.Replace(html, "<(h[23])(\\s[^>]*)?>", delegate(Match match)
{
string value = match.Groups[1].Value;
string value2 = match.Groups[2].Value;
counter++;
return (!value2.Contains("id=", StringComparison.OrdinalIgnoreCase)) ? $"<{value}{value2} id=\"section-{counter}\">" : match.Value;
});
}
private static string AddNumberedClass(string html)
{
return Regex.Replace(html, "<(h[23])(\\s[^>]*)?>", delegate(Match match)
{
string value = match.Groups[1].Value;
string value2 = match.Groups[2].Value;
if (value2.Contains("numbered", StringComparison.OrdinalIgnoreCase))
{
return match.Value;
}
return Regex.IsMatch(value2, "class\\s*=\\s*\"", RegexOptions.IgnoreCase) ? Regex.Replace(match.Value, "class\\s*=\\s*\"", "class=\"numbered ") : ("<" + value + value2 + " class=\"numbered\">");
});
}
private static string GenerateToc(string html)
{
MatchCollection matchCollection = Regex.Matches(html, "<(h[23])[^>]*id=\"([^\"]+)\"[^>]*>(.*?)</\\1>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
if (matchCollection.Count == 0)
{
return "";
}
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("<nav class=\"toc\">");
stringBuilder.AppendLine("<h2>\ud83d\udccb 목차</h2>");
stringBuilder.AppendLine("<ul>");
foreach (Match item in matchCollection)
{
string text = item.Groups[1].Value.ToLower();
string value = item.Groups[2].Value;
string value2 = Regex.Replace(item.Groups[3].Value, "<[^>]+>", "").Trim();
string value3 = ((text == "h3") ? " class=\"toc-h3\"" : "");
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(25, 3, stringBuilder2);
handler.AppendLiteral("<li");
handler.AppendFormatted(value3);
handler.AppendLiteral("><a href=\"#");
handler.AppendFormatted(value);
handler.AppendLiteral("\">");
handler.AppendFormatted(value2);
handler.AppendLiteral("</a></li>");
stringBuilder2.AppendLine(ref handler);
}
stringBuilder.AppendLine("</ul>");
stringBuilder.AppendLine("</nav>");
return stringBuilder.ToString();
}
private static string GenerateCover(JsonElement cover, string fallbackTitle)
{
JsonElement value;
string s = (cover.TryGetProperty("title", out value) ? (value.GetString() ?? fallbackTitle) : fallbackTitle);
JsonElement value2;
string text = (cover.TryGetProperty("subtitle", out value2) ? (value2.GetString() ?? "") : "");
JsonElement value3;
string text2 = (cover.TryGetProperty("author", out value3) ? (value3.GetString() ?? "") : "");
JsonElement value4;
string item = (cover.TryGetProperty("date", out value4) ? (value4.GetString() ?? DateTime.Now.ToString("yyyy-MM-dd")) : DateTime.Now.ToString("yyyy-MM-dd"));
JsonElement value5;
string text3 = (cover.TryGetProperty("gradient", out value5) ? value5.GetString() : null);
string value6 = "";
if (!string.IsNullOrEmpty(text3) && text3.Contains(','))
{
string[] array = text3.Split(',');
value6 = $" style=\"background: linear-gradient(135deg, {array[0].Trim()} 0%, {array[1].Trim()} 100%)\"";
}
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(24, 1, stringBuilder2);
handler.AppendLiteral("<div class=\"cover-page\"");
handler.AppendFormatted(value6);
handler.AppendLiteral(">");
stringBuilder3.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("<h1>");
handler.AppendFormatted(Escape(s));
handler.AppendLiteral("</h1>");
stringBuilder4.AppendLine(ref handler);
if (!string.IsNullOrEmpty(text))
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(34, 1, stringBuilder2);
handler.AppendLiteral("<div class=\"cover-subtitle\">");
handler.AppendFormatted(Escape(text));
handler.AppendLiteral("</div>");
stringBuilder5.AppendLine(ref handler);
}
stringBuilder.AppendLine("<div class=\"cover-divider\"></div>");
List<string> list = new List<string>();
if (!string.IsNullOrEmpty(text2))
{
list.Add(text2);
}
list.Add(item);
list.Add("AX Copilot");
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(30, 1, stringBuilder2);
handler.AppendLiteral("<div class=\"cover-meta\">");
handler.AppendFormatted(Escape(string.Join(" · ", list)));
handler.AppendLiteral("</div>");
stringBuilder6.AppendLine(ref handler);
stringBuilder.AppendLine("</div>");
return stringBuilder.ToString();
}
private static string Escape(string s)
{
return s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}
}

View File

@@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public class HttpTool : IAgentTool
{
private static readonly HttpClient _client = new HttpClient
{
Timeout = TimeSpan.FromSeconds(30.0)
};
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "HTTP method"
};
int num = 5;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "GET";
span[1] = "POST";
span[2] = "PUT";
span[3] = "DELETE";
span[4] = "PATCH";
obj.Enum = list;
dictionary["method"] = obj;
dictionary["url"] = new ToolProperty
{
Type = "string",
Description = "Request URL (localhost or internal network only)"
};
dictionary["body"] = new ToolProperty
{
Type = "string",
Description = "Request body (JSON string, for POST/PUT/PATCH)"
};
dictionary["headers"] = new ToolProperty
{
Type = "string",
Description = "Custom headers as JSON object, e.g. {\"Authorization\": \"Bearer token\"}"
};
dictionary["timeout"] = new ToolProperty
{
Type = "string",
Description = "Request timeout in seconds (default: 30, max: 120)"
};
toolParameterSchema.Properties = dictionary;
num = 2;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
Span<string> span2 = CollectionsMarshal.AsSpan(list2);
span2[0] = "method";
span2[1] = "url";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string method = args.GetProperty("method").GetString()?.ToUpperInvariant() ?? "GET";
string url = args.GetProperty("url").GetString() ?? "";
JsonElement b;
string body = (args.TryGetProperty("body", out b) ? (b.GetString() ?? "") : "");
JsonElement h;
string headers = (args.TryGetProperty("headers", out h) ? (h.GetString() ?? "") : "");
JsonElement t;
int ts;
int timeout = ((!args.TryGetProperty("timeout", out t)) ? 30 : (int.TryParse(t.GetString(), out ts) ? Math.Min(ts, 120) : 30));
if (!IsAllowedHost(url))
{
return ToolResult.Fail("보안 제한: localhost, 127.0.0.1, 사내 네트워크(10.x, 172.16-31.x, 192.168.x)만 허용됩니다.");
}
try
{
HttpMethod httpMethod = new HttpMethod(method);
using HttpRequestMessage request = new HttpRequestMessage(httpMethod, url);
if (!string.IsNullOrEmpty(headers))
{
using JsonDocument headerDoc = JsonDocument.Parse(headers);
foreach (JsonProperty prop in headerDoc.RootElement.EnumerateObject())
{
request.Headers.TryAddWithoutValidation(prop.Name, prop.Value.GetString());
}
}
bool flag = !string.IsNullOrEmpty(body);
bool flag2 = flag;
if (flag2)
{
bool flag3;
switch (method)
{
case "POST":
case "PUT":
case "PATCH":
flag3 = true;
break;
default:
flag3 = false;
break;
}
flag2 = flag3;
}
if (flag2)
{
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
}
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
using HttpResponseMessage response = await _client.SendAsync(request, cts.Token);
int statusCode = (int)response.StatusCode;
string responseBody = await response.Content.ReadAsStringAsync(cts.Token);
StringBuilder sb = new StringBuilder();
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(6, 2, stringBuilder);
handler.AppendLiteral("HTTP ");
handler.AppendFormatted(statusCode);
handler.AppendLiteral(" ");
handler.AppendFormatted(response.ReasonPhrase);
stringBuilder2.AppendLine(ref handler);
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(14, 1, stringBuilder);
handler.AppendLiteral("Content-Type: ");
handler.AppendFormatted(response.Content.Headers.ContentType);
stringBuilder3.AppendLine(ref handler);
sb.AppendLine();
MediaTypeHeaderValue? contentType = response.Content.Headers.ContentType;
if (contentType != null && contentType.MediaType?.Contains("json") == true)
{
try
{
using JsonDocument doc = JsonDocument.Parse(responseBody);
responseBody = JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions
{
WriteIndented = true
});
}
catch
{
}
}
if (responseBody.Length > 8000)
{
responseBody = responseBody.Substring(0, 8000) + $"\n... (truncated, total {responseBody.Length} chars)";
}
sb.Append(responseBody);
return ToolResult.Ok(sb.ToString());
}
catch (TaskCanceledException)
{
return ToolResult.Fail($"요청 시간 초과 ({timeout}초)");
}
catch (Exception ex2)
{
return ToolResult.Fail("HTTP 요청 실패: " + ex2.Message);
}
}
private static bool IsAllowedHost(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out Uri result))
{
return false;
}
string host = result.Host;
bool flag;
switch (host)
{
case "localhost":
case "127.0.0.1":
case "::1":
flag = true;
break;
default:
flag = false;
break;
}
if (flag)
{
return true;
}
if (IPAddress.TryParse(host, out IPAddress address))
{
byte[] addressBytes = address.GetAddressBytes();
if (addressBytes.Length == 4)
{
if (addressBytes[0] == 10)
{
return true;
}
if (addressBytes[0] == 172 && addressBytes[1] >= 16 && addressBytes[1] <= 31)
{
return true;
}
if (addressBytes[0] == 192 && addressBytes[1] == 168)
{
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,16 @@
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public interface IAgentTool
{
string Name { get; }
string Description { get; }
ToolParameterSchema Parameters { get; }
Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken));
}

View File

@@ -0,0 +1,253 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> obj = new Dictionary<string, ToolProperty> { ["image_path"] = new ToolProperty
{
Type = "string",
Description = "Path to the image file (.png, .jpg, .jpeg, .bmp, .gif, .webp)."
} };
ToolProperty obj2 = new ToolProperty
{
Type = "string",
Description = "Analysis task: describe, extract_text, extract_data, compare. Default: describe"
};
int num = 4;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "describe";
span[1] = "extract_text";
span[2] = "extract_data";
span[3] = "compare";
obj2.Enum = list;
obj["task"] = obj2;
obj["compare_path"] = new ToolProperty
{
Type = "string",
Description = "Path to second image for comparison (only used with task=compare)."
};
obj["question"] = new ToolProperty
{
Type = "string",
Description = "Optional specific question about the image."
};
obj["language"] = new ToolProperty
{
Type = "string",
Description = "Response language: ko (Korean), en (English). Default: ko"
};
toolParameterSchema.Properties = obj;
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "image_path";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string imagePath = args.GetProperty("image_path").GetString() ?? "";
JsonElement taskEl;
string task = (args.TryGetProperty("task", out taskEl) ? (taskEl.GetString() ?? "describe") : "describe");
JsonElement qEl;
string question = (args.TryGetProperty("question", out qEl) ? (qEl.GetString() ?? "") : "");
JsonElement langEl;
string language = (args.TryGetProperty("language", out langEl) ? (langEl.GetString() ?? "ko") : "ko");
string fullPath = FileReadTool.ResolvePath(imagePath, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
{
return ToolResult.Fail("경로 접근 차단: " + fullPath);
}
if (!File.Exists(fullPath))
{
return ToolResult.Fail("파일 없음: " + fullPath);
}
string ext = Path.GetExtension(fullPath).ToLowerInvariant();
if (!IsImageExtension(ext))
{
return ToolResult.Fail("지원하지 않는 이미지 형식: " + ext);
}
byte[] imageBytes = await File.ReadAllBytesAsync(fullPath, ct);
string base64 = Convert.ToBase64String(imageBytes);
if (1 == 0)
{
}
string text;
switch (ext)
{
case ".png":
text = "image/png";
break;
case ".jpg":
case ".jpeg":
text = "image/jpeg";
break;
case ".gif":
text = "image/gif";
break;
case ".webp":
text = "image/webp";
break;
case ".bmp":
text = "image/bmp";
break;
default:
text = "image/png";
break;
}
if (1 == 0)
{
}
string mimeType = text;
if (imageBytes.Length > 10485760)
{
return ToolResult.Fail("이미지 크기가 10MB를 초과합니다.");
}
string compareBase64 = null;
string compareMime = null;
if (task == "compare" && args.TryGetProperty("compare_path", out var cpEl))
{
string comparePath = FileReadTool.ResolvePath(cpEl.GetString() ?? "", context.WorkFolder);
if (File.Exists(comparePath) && context.IsPathAllowed(comparePath))
{
compareBase64 = Convert.ToBase64String(await File.ReadAllBytesAsync(comparePath, ct));
string compareExt = Path.GetExtension(comparePath).ToLowerInvariant();
if (1 == 0)
{
}
switch (compareExt)
{
case ".png":
text = "image/png";
break;
case ".jpg":
case ".jpeg":
text = "image/jpeg";
break;
case ".gif":
text = "image/gif";
break;
case ".webp":
text = "image/webp";
break;
default:
text = "image/png";
break;
}
if (1 == 0)
{
}
compareMime = text;
}
}
string langPrompt = ((language == "en") ? "Respond in English." : "한국어로 응답하세요.");
if (1 == 0)
{
}
text = task switch
{
"extract_text" => "이 이미지에서 모든 텍스트를 추출하세요. 원본 레이아웃을 최대한 유지하세요. " + langPrompt,
"extract_data" => "이 이미지에서 구조화된 데이터를 추출하세요. 테이블, 차트, 그래프 등의 데이터를 CSV 또는 JSON 형식으로 변환하세요. 차트의 경우 각 항목의 값을 추정하세요. " + langPrompt,
"compare" => "두 이미지를 비교하고 차이점을 설명하세요. " + langPrompt,
_ => string.IsNullOrEmpty(question) ? ("이 이미지의 내용을 상세하게 설명하세요. 주요 요소, 텍스트, 레이아웃, 색상 등을 포함하세요. " + langPrompt) : (question + " " + langPrompt),
};
if (1 == 0)
{
}
string prompt = text;
StringBuilder info = new StringBuilder();
info.AppendLine("\ud83d\uddbc 이미지 분석 준비 완료");
StringBuilder stringBuilder = info;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(6, 1, stringBuilder);
handler.AppendLiteral(" 파일: ");
handler.AppendFormatted(Path.GetFileName(fullPath));
stringBuilder2.AppendLine(ref handler);
stringBuilder = info;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(8, 1, stringBuilder);
handler.AppendLiteral(" 크기: ");
handler.AppendFormatted(imageBytes.Length / 1024);
handler.AppendLiteral("KB");
stringBuilder3.AppendLine(ref handler);
stringBuilder = info;
StringBuilder stringBuilder4 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(6, 1, stringBuilder);
handler.AppendLiteral(" 형식: ");
handler.AppendFormatted(mimeType);
stringBuilder4.AppendLine(ref handler);
stringBuilder = info;
StringBuilder stringBuilder5 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(6, 1, stringBuilder);
handler.AppendLiteral(" 작업: ");
handler.AppendFormatted(task);
stringBuilder5.AppendLine(ref handler);
info.AppendLine();
stringBuilder = info;
StringBuilder stringBuilder6 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(30, 2, stringBuilder);
handler.AppendLiteral("[IMAGE_BASE64:");
handler.AppendFormatted(mimeType);
handler.AppendLiteral("]");
handler.AppendFormatted(base64);
handler.AppendLiteral("[/IMAGE_BASE64]");
stringBuilder6.AppendLine(ref handler);
if (compareBase64 != null)
{
stringBuilder = info;
StringBuilder stringBuilder7 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(30, 2, stringBuilder);
handler.AppendLiteral("[IMAGE_BASE64:");
handler.AppendFormatted(compareMime);
handler.AppendLiteral("]");
handler.AppendFormatted(compareBase64);
handler.AppendLiteral("[/IMAGE_BASE64]");
stringBuilder7.AppendLine(ref handler);
}
info.AppendLine();
stringBuilder = info;
StringBuilder stringBuilder8 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder);
handler.AppendLiteral("분석 프롬프트: ");
handler.AppendFormatted(prompt);
stringBuilder8.AppendLine(ref handler);
return ToolResult.Ok(info.ToString());
}
private static bool IsImageExtension(string ext)
{
switch (ext)
{
case ".png":
case ".jpg":
case ".jpeg":
case ".gif":
case ".bmp":
case ".webp":
return true;
default:
return false;
}
}
}

View File

@@ -0,0 +1,330 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public class JsonTool : IAgentTool
{
private record PathSegment(string Key, int Index, bool IsIndex);
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Action to perform"
};
int num = 5;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "validate";
span[1] = "format";
span[2] = "query";
span[3] = "keys";
span[4] = "convert";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["json"] = new ToolProperty
{
Type = "string",
Description = "JSON text to process"
};
dictionary["path"] = new ToolProperty
{
Type = "string",
Description = "Dot-path for query action (e.g. 'data.items[0].name')"
};
dictionary["minify"] = new ToolProperty
{
Type = "string",
Description = "For format action: 'true' to minify, 'false' to pretty-print (default)"
};
ToolProperty obj2 = new ToolProperty
{
Type = "string",
Description = "For convert action: target format"
};
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "csv";
obj2.Enum = list2;
dictionary["target_format"] = obj2;
toolParameterSchema.Properties = dictionary;
num = 2;
List<string> list3 = new List<string>(num);
CollectionsMarshal.SetCount(list3, num);
Span<string> span2 = CollectionsMarshal.AsSpan(list3);
span2[0] = "action";
span2[1] = "json";
toolParameterSchema.Required = list3;
return toolParameterSchema;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string text = args.GetProperty("action").GetString() ?? "";
string json = args.GetProperty("json").GetString() ?? "";
try
{
if (1 == 0)
{
}
JsonElement value;
JsonElement value2;
JsonElement value3;
ToolResult result = text switch
{
"validate" => Validate(json),
"format" => Format(json, args.TryGetProperty("minify", out value) && value.GetString() == "true"),
"query" => Query(json, args.TryGetProperty("path", out value2) ? (value2.GetString() ?? "") : ""),
"keys" => Keys(json),
"convert" => Convert(json, args.TryGetProperty("target_format", out value3) ? (value3.GetString() ?? "csv") : "csv"),
_ => ToolResult.Fail("Unknown action: " + text),
};
if (1 == 0)
{
}
return Task.FromResult(result);
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail("JSON 처리 오류: " + ex.Message));
}
}
private static ToolResult Validate(string json)
{
try
{
using JsonDocument jsonDocument = JsonDocument.Parse(json);
JsonElement rootElement = jsonDocument.RootElement;
JsonValueKind valueKind = rootElement.ValueKind;
if (1 == 0)
{
}
string text = valueKind switch
{
JsonValueKind.Object => $"Object ({rootElement.EnumerateObject().Count()} keys)",
JsonValueKind.Array => $"Array ({rootElement.GetArrayLength()} items)",
_ => rootElement.ValueKind.ToString(),
};
if (1 == 0)
{
}
string text2 = text;
return ToolResult.Ok("✓ Valid JSON — " + text2);
}
catch (JsonException ex)
{
return ToolResult.Ok("✗ Invalid JSON — " + ex.Message);
}
}
private static ToolResult Format(string json, bool minify)
{
using JsonDocument jsonDocument = JsonDocument.Parse(json);
JsonSerializerOptions options = new JsonSerializerOptions
{
WriteIndented = !minify
};
string text = JsonSerializer.Serialize(jsonDocument.RootElement, options);
if (text.Length > 8000)
{
text = text.Substring(0, 8000) + "\n... (truncated)";
}
return ToolResult.Ok(text);
}
private static ToolResult Query(string json, string path)
{
if (string.IsNullOrEmpty(path))
{
return ToolResult.Fail("path parameter is required for query action");
}
using JsonDocument jsonDocument = JsonDocument.Parse(json);
JsonElement value = jsonDocument.RootElement;
foreach (PathSegment item in ParsePath(path))
{
if (item.IsIndex)
{
if (value.ValueKind != JsonValueKind.Array || item.Index >= value.GetArrayLength())
{
return ToolResult.Fail($"Array index [{item.Index}] out of range");
}
value = value[item.Index];
}
else
{
if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(item.Key, out var value2))
{
return ToolResult.Fail("Key '" + item.Key + "' not found");
}
value = value2;
}
}
JsonValueKind valueKind = value.ValueKind;
if (1 == 0)
{
}
string text = valueKind switch
{
JsonValueKind.String => value.GetString() ?? "",
JsonValueKind.Number => value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => "null",
_ => JsonSerializer.Serialize(value, new JsonSerializerOptions
{
WriteIndented = true
}),
};
if (1 == 0)
{
}
string text2 = text;
if (text2.Length > 5000)
{
text2 = text2.Substring(0, 5000) + "\n... (truncated)";
}
return ToolResult.Ok(text2);
}
private static ToolResult Keys(string json)
{
using JsonDocument jsonDocument = JsonDocument.Parse(json);
if (jsonDocument.RootElement.ValueKind != JsonValueKind.Object)
{
return ToolResult.Fail("Root element is not an object");
}
IEnumerable<string> values = jsonDocument.RootElement.EnumerateObject().Select(delegate(JsonProperty p)
{
JsonValueKind valueKind = p.Value.ValueKind;
if (1 == 0)
{
}
string text;
switch (valueKind)
{
case JsonValueKind.Object:
text = "object";
break;
case JsonValueKind.Array:
text = $"array[{p.Value.GetArrayLength()}]";
break;
case JsonValueKind.String:
text = "string";
break;
case JsonValueKind.Number:
text = "number";
break;
case JsonValueKind.True:
case JsonValueKind.False:
text = "boolean";
break;
default:
text = "null";
break;
}
if (1 == 0)
{
}
string text2 = text;
return " " + p.Name + ": " + text2;
});
return ToolResult.Ok($"Keys ({jsonDocument.RootElement.EnumerateObject().Count()}):\n{string.Join("\n", values)}");
}
private static ToolResult Convert(string json, string targetFormat)
{
if (targetFormat != "csv")
{
return ToolResult.Fail("Unsupported target format: " + targetFormat);
}
using JsonDocument jsonDocument = JsonDocument.Parse(json);
if (jsonDocument.RootElement.ValueKind != JsonValueKind.Array)
{
return ToolResult.Fail("JSON must be an array for CSV conversion");
}
JsonElement rootElement = jsonDocument.RootElement;
if (rootElement.GetArrayLength() == 0)
{
return ToolResult.Ok("(empty array)");
}
List<string> list = new List<string>();
foreach (JsonElement item2 in rootElement.EnumerateArray())
{
if (item2.ValueKind != JsonValueKind.Object)
{
return ToolResult.Fail("All array items must be objects for CSV conversion");
}
foreach (JsonProperty item3 in item2.EnumerateObject())
{
if (!list.Contains(item3.Name))
{
list.Add(item3.Name);
}
}
}
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine(string.Join(",", list.Select((string k) => "\"" + k + "\"")));
foreach (JsonElement item in rootElement.EnumerateArray())
{
JsonElement value;
IEnumerable<string> values = list.Select((string k) => (!item.TryGetProperty(k, out value)) ? "\"\"" : ((value.ValueKind == JsonValueKind.String) ? ("\"" + value.GetString()?.Replace("\"", "\"\"") + "\"") : value.GetRawText()));
stringBuilder.AppendLine(string.Join(",", values));
}
string text = stringBuilder.ToString();
if (text.Length > 8000)
{
text = text.Substring(0, 8000) + "\n... (truncated)";
}
return ToolResult.Ok(text);
}
private static List<PathSegment> ParsePath(string path)
{
List<PathSegment> list = new List<PathSegment>();
string[] array = path.Split('.');
foreach (string text in array)
{
int num = text.IndexOf('[');
if (num >= 0)
{
string text2 = text.Substring(0, num);
if (!string.IsNullOrEmpty(text2))
{
list.Add(new PathSegment(text2, 0, IsIndex: false));
}
string text3 = text;
int num2 = num + 1;
string s = text3.Substring(num2, text3.Length - num2).TrimEnd(']');
if (int.TryParse(s, out var result))
{
list.Add(new PathSegment("", result, IsIndex: true));
}
}
else
{
list.Add(new PathSegment(text, 0, IsIndex: false));
}
}
return list;
}
}

View File

@@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
namespace AxCopilot.Services.Agent;
public class LspTool : IAgentTool, IDisposable
{
private readonly Dictionary<string, LspClientService> _clients = new Dictionary<string, LspClientService>();
public string Name => "lsp_code_intel";
public string Description => "코드 인텔리전스 도구. 정의 이동, 참조 검색, 심볼 목록을 제공합니다.\n- action=\"goto_definition\": 심볼의 정의 위치를 찾습니다 (파일, 라인, 컬럼)\n- action=\"find_references\": 심볼이 사용된 모든 위치를 찾습니다\n- action=\"symbols\": 파일 내 모든 심볼(클래스, 메서드, 필드 등)을 나열합니다\nfile_path, line, character 파라미터가 필요합니다 (line과 character는 0-based).";
public ToolParameterSchema Parameters => new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["action"] = new ToolProperty
{
Type = "string",
Description = "수행할 작업: goto_definition | find_references | symbols",
Enum = new List<string> { "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 List<string> { "action", "file_path" }
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
if (!((Application.Current as App)?.SettingsService?.Settings.Llm.Code.EnableLsp ?? true))
{
return ToolResult.Ok("LSP 코드 인텔리전스가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
}
JsonElement a;
string action = (args.TryGetProperty("action", out a) ? (a.GetString() ?? "") : "");
JsonElement f;
string filePath = (args.TryGetProperty("file_path", out f) ? (f.GetString() ?? "") : "");
JsonElement l;
int line = (args.TryGetProperty("line", out l) ? l.GetInt32() : 0);
JsonElement ch;
int character = (args.TryGetProperty("character", out 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);
}
string language = DetectLanguage(filePath);
if (language == null)
{
return ToolResult.Fail("지원하지 않는 파일 형식: " + Path.GetExtension(filePath));
}
LspClientService client = await GetOrCreateClientAsync(language, context.WorkFolder, ct);
if (client == null || !client.IsConnected)
{
return ToolResult.Fail(language + " 언어 서버를 시작할 수 없습니다. 해당 언어 서버가 설치되어 있는지 확인하세요.");
}
try
{
if (1 == 0)
{
}
ToolResult result = 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 중 선택하세요."),
};
if (1 == 0)
{
}
return result;
}
catch (Exception ex)
{
return ToolResult.Fail("LSP 오류: " + ex.Message);
}
}
private async Task<ToolResult> GotoDefinitionAsync(LspClientService client, string filePath, int line, int character, CancellationToken ct)
{
LspLocation loc = await client.GotoDefinitionAsync(filePath, line, character, ct);
if (loc == null)
{
return ToolResult.Ok("정의를 찾을 수 없습니다 (해당 위치에 심볼이 없거나 외부 라이브러리일 수 있습니다).");
}
string 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)
{
List<LspLocation> locations = await client.FindReferencesAsync(filePath, line, character, ct);
if (locations.Count == 0)
{
return ToolResult.Ok("참조를 찾을 수 없습니다.");
}
StringBuilder sb = new StringBuilder();
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder);
handler.AppendLiteral("총 ");
handler.AppendFormatted(locations.Count);
handler.AppendLiteral("개 참조:");
stringBuilder2.AppendLine(ref handler);
foreach (LspLocation loc in locations.Take(30))
{
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(2, 1, stringBuilder);
handler.AppendLiteral(" ");
handler.AppendFormatted(loc);
stringBuilder3.AppendLine(ref handler);
}
if (locations.Count > 30)
{
stringBuilder = sb;
StringBuilder stringBuilder4 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder);
handler.AppendLiteral(" ... 외 ");
handler.AppendFormatted(locations.Count - 30);
handler.AppendLiteral("개");
stringBuilder4.AppendLine(ref handler);
}
return ToolResult.Ok(sb.ToString());
}
private async Task<ToolResult> GetSymbolsAsync(LspClientService client, string filePath, CancellationToken ct)
{
List<LspSymbol> symbols = await client.GetDocumentSymbolsAsync(filePath, ct);
if (symbols.Count == 0)
{
return ToolResult.Ok("심볼을 찾을 수 없습니다.");
}
StringBuilder sb = new StringBuilder();
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder);
handler.AppendLiteral("총 ");
handler.AppendFormatted(symbols.Count);
handler.AppendLiteral("개 심볼:");
stringBuilder2.AppendLine(ref handler);
foreach (LspSymbol sym in symbols)
{
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(2, 1, stringBuilder);
handler.AppendLiteral(" ");
handler.AppendFormatted(sym);
stringBuilder3.AppendLine(ref handler);
}
return ToolResult.Ok(sb.ToString());
}
private async Task<LspClientService?> GetOrCreateClientAsync(string language, string workFolder, CancellationToken ct)
{
if (_clients.TryGetValue(language, out LspClientService existing) && existing.IsConnected)
{
return existing;
}
LspClientService client = new LspClientService(language);
if (!(await client.StartAsync(workFolder, ct)))
{
client.Dispose();
return null;
}
_clients[language] = client;
return client;
}
private static string? DetectLanguage(string filePath)
{
string text = Path.GetExtension(filePath).ToLowerInvariant();
if (1 == 0)
{
}
string result;
switch (text)
{
case ".cs":
result = "csharp";
break;
case ".ts":
case ".tsx":
result = "typescript";
break;
case ".js":
case ".jsx":
result = "javascript";
break;
case ".py":
result = "python";
break;
case ".cpp":
case ".cc":
case ".cxx":
case ".c":
case ".h":
case ".hpp":
result = "cpp";
break;
case ".java":
result = "java";
break;
default:
result = null;
break;
}
if (1 == 0)
{
}
return result;
}
public void Dispose()
{
foreach (LspClientService value in _clients.Values)
{
value.Dispose();
}
_clients.Clear();
}
private static string ReadCodeContext(string filePath, int targetLine, int contextLines)
{
try
{
string[] array = File.ReadAllLines(filePath);
int num = Math.Max(0, targetLine - contextLines);
int num2 = Math.Min(array.Length - 1, targetLine + contextLines);
StringBuilder stringBuilder = new StringBuilder();
for (int i = num; i <= num2; i++)
{
string value = ((i == targetLine) ? ">>>" : " ");
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(3, 3, stringBuilder2);
handler.AppendFormatted(value);
handler.AppendLiteral(" ");
handler.AppendFormatted(i + 1, 4);
handler.AppendLiteral(": ");
handler.AppendFormatted(array[i]);
stringBuilder2.AppendLine(ref handler);
}
return stringBuilder.ToString().TrimEnd();
}
catch
{
return "(코드를 읽을 수 없습니다)";
}
}
}

View File

@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "Output file path (.md). Relative to work folder."
},
["content"] = new ToolProperty
{
Type = "string",
Description = "Markdown content to write"
},
["title"] = new ToolProperty
{
Type = "string",
Description = "Optional document title. If provided, prepends '# title' at the top."
}
}
};
int num = 2;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "path";
span[1] = "content";
obj.Required = list;
return obj;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string path = args.GetProperty("path").GetString() ?? "";
string content = args.GetProperty("content").GetString() ?? "";
JsonElement t;
string title = (args.TryGetProperty("title", out t) ? t.GetString() : null);
string 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
{
string dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
StringBuilder sb = new StringBuilder();
if (!string.IsNullOrEmpty(title))
{
StringBuilder stringBuilder = sb;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(2, 1, stringBuilder);
handler.AppendLiteral("# ");
handler.AppendFormatted(title);
stringBuilder.AppendLine(ref handler);
sb.AppendLine();
}
sb.Append(content);
await File.WriteAllTextAsync(fullPath, sb.ToString(), Encoding.UTF8, ct);
int 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,74 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["expression"] = new ToolProperty
{
Type = "string",
Description = "Mathematical expression to evaluate (e.g. '(100 * 1.08) / 3')"
},
["precision"] = new ToolProperty
{
Type = "integer",
Description = "Decimal places for rounding (default: 6)"
}
}
};
int num = 1;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
CollectionsMarshal.AsSpan(list)[0] = "expression";
obj.Required = list;
return obj;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string text = args.GetProperty("expression").GetString() ?? "";
JsonElement value;
int digits = (args.TryGetProperty("precision", out value) ? value.GetInt32() : 6);
if (string.IsNullOrWhiteSpace(text))
{
return Task.FromResult(ToolResult.Fail("수식이 비어 있습니다."));
}
try
{
string text2 = text.Replace("^", " ").Replace("Math.", "").Replace("System.", "");
if (Regex.IsMatch(text2, "[a-zA-Z]{3,}"))
{
return Task.FromResult(ToolResult.Fail("함수 호출은 지원하지 않습니다. 기본 사칙연산만 가능합니다."));
}
DataTable dataTable = new DataTable();
object value2 = dataTable.Compute(text2, null);
double value3 = Convert.ToDouble(value2);
double value4 = Math.Round(value3, digits);
return Task.FromResult(ToolResult.Ok($"Expression: {text}\nResult: {value4}"));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail("수식 평가 오류: " + ex.Message));
}
}
}

View File

@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public class McpTool : IAgentTool
{
private readonly McpClientService _client;
private readonly McpToolDefinition _def;
public string Name => "mcp_" + _def.ServerName + "_" + _def.Name;
public string Description => "[MCP:" + _def.ServerName + "] " + _def.Description;
public ToolParameterSchema Parameters
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>(),
Required = new List<string>()
};
foreach (var (text2, mcpParameterDef2) in _def.Parameters)
{
toolParameterSchema.Properties[text2] = new ToolProperty
{
Type = mcpParameterDef2.Type,
Description = mcpParameterDef2.Description
};
if (mcpParameterDef2.Required)
{
toolParameterSchema.Required.Add(text2);
}
}
return toolParameterSchema;
}
}
public McpTool(McpClientService client, McpToolDefinition def)
{
_client = client;
_def = def;
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
try
{
if (!_client.IsConnected)
{
return ToolResult.Fail("MCP 서버 '" + _def.ServerName + "'에 연결되어 있지 않습니다.");
}
Dictionary<string, object> arguments = new Dictionary<string, object>();
if (args.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty prop in args.EnumerateObject())
{
Dictionary<string, object> dictionary = arguments;
string name = prop.Name;
JsonValueKind valueKind = prop.Value.ValueKind;
if (1 == 0)
{
}
object value = valueKind switch
{
JsonValueKind.String => prop.Value.GetString(),
JsonValueKind.Number => prop.Value.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => prop.Value.ToString(),
};
if (1 == 0)
{
}
dictionary[name] = value;
}
}
return ToolResult.Ok(await _client.CallToolAsync(_def.Name, arguments, ct));
}
catch (Exception ex)
{
Exception ex2 = ex;
return ToolResult.Fail("MCP 도구 실행 실패: " + ex2.Message);
}
}
}

View File

@@ -0,0 +1,209 @@
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
namespace AxCopilot.Services.Agent;
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 필수)\ntype 종류: rule(프로젝트 규칙), preference(사용자 선호), fact(사실), correction(실수 교정)";
public ToolParameterSchema Parameters
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["action"] = new ToolProperty
{
Type = "string",
Description = "save | search | list | delete"
},
["type"] = new ToolProperty
{
Type = "string",
Description = "메모리 유형: rule | preference | fact | correction. save 시 필수."
},
["content"] = new ToolProperty
{
Type = "string",
Description = "저장할 내용. save 시 필수."
},
["query"] = new ToolProperty
{
Type = "string",
Description = "검색 쿼리. search 시 필수."
},
["id"] = new ToolProperty
{
Type = "string",
Description = "메모리 ID. delete 시 필수."
}
}
};
int num = 1;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
CollectionsMarshal.AsSpan(list)[0] = "action";
obj.Required = list;
return obj;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
App app = Application.Current as App;
if (!(app?.SettingsService?.Settings.Llm.EnableAgentMemory ?? true))
{
return Task.FromResult(ToolResult.Ok("에이전트 메모리가 비활성 상태입니다. 설정에서 활성화하세요."));
}
AgentMemoryService agentMemoryService = app?.MemoryService;
if (agentMemoryService == null)
{
return Task.FromResult(ToolResult.Fail("메모리 서비스를 사용할 수 없습니다."));
}
if (!args.TryGetProperty("action", out var value))
{
return Task.FromResult(ToolResult.Fail("action이 필요합니다."));
}
string text = value.GetString() ?? "";
if (1 == 0)
{
}
ToolResult result = text switch
{
"save" => ExecuteSave(args, agentMemoryService, context),
"search" => ExecuteSearch(args, agentMemoryService),
"list" => ExecuteList(agentMemoryService),
"delete" => ExecuteDelete(args, agentMemoryService),
_ => ToolResult.Fail("알 수 없는 액션: " + text + ". save | search | list | delete 중 선택하세요."),
};
if (1 == 0)
{
}
return Task.FromResult(result);
}
private static ToolResult ExecuteSave(JsonElement args, AgentMemoryService svc, AgentContext context)
{
JsonElement value;
string text = (args.TryGetProperty("type", out value) ? (value.GetString() ?? "fact") : "fact");
JsonElement value2;
string text2 = (args.TryGetProperty("content", out value2) ? (value2.GetString() ?? "") : "");
if (string.IsNullOrWhiteSpace(text2))
{
return ToolResult.Fail("content가 필요합니다.");
}
string[] source = new string[4] { "rule", "preference", "fact", "correction" };
if (!source.Contains(text))
{
return ToolResult.Fail("잘못된 type: " + text + ". rule | preference | fact | correction 중 선택하세요.");
}
string workFolder = (string.IsNullOrEmpty(context.WorkFolder) ? null : context.WorkFolder);
MemoryEntry memoryEntry = svc.Add(text, text2, "agent:" + context.ActiveTab, workFolder);
return ToolResult.Ok($"메모리 저장됨 [{memoryEntry.Type}] (ID: {memoryEntry.Id}): {memoryEntry.Content}");
}
private static ToolResult ExecuteSearch(JsonElement args, AgentMemoryService svc)
{
JsonElement value;
string text = (args.TryGetProperty("query", out value) ? (value.GetString() ?? "") : "");
if (string.IsNullOrWhiteSpace(text))
{
return ToolResult.Fail("query가 필요합니다.");
}
List<MemoryEntry> relevant = svc.GetRelevant(text);
if (relevant.Count == 0)
{
return ToolResult.Ok("관련 메모리가 없습니다.");
}
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("관련 메모리 ");
handler.AppendFormatted(relevant.Count);
handler.AppendLiteral("개:");
stringBuilder3.AppendLine(ref handler);
foreach (MemoryEntry item in relevant)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(18, 4, stringBuilder2);
handler.AppendLiteral(" [");
handler.AppendFormatted(item.Type);
handler.AppendLiteral("] ");
handler.AppendFormatted(item.Content);
handler.AppendLiteral(" (사용 ");
handler.AppendFormatted(item.UseCount);
handler.AppendLiteral("회, ID: ");
handler.AppendFormatted(item.Id);
handler.AppendLiteral(")");
stringBuilder4.AppendLine(ref handler);
}
return ToolResult.Ok(stringBuilder.ToString());
}
private static ToolResult ExecuteList(AgentMemoryService svc)
{
IReadOnlyList<MemoryEntry> all = svc.All;
if (all.Count == 0)
{
return ToolResult.Ok("저장된 메모리가 없습니다.");
}
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("전체 메모리 ");
handler.AppendFormatted(all.Count);
handler.AppendLiteral("개:");
stringBuilder3.AppendLine(ref handler);
foreach (IGrouping<string, MemoryEntry> item in from e in all
group e by e.Type)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2);
handler.AppendLiteral("\n[");
handler.AppendFormatted(item.Key);
handler.AppendLiteral("]");
stringBuilder4.AppendLine(ref handler);
foreach (MemoryEntry item2 in item.OrderByDescending((MemoryEntry e) => e.UseCount))
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(17, 3, stringBuilder2);
handler.AppendLiteral(" • ");
handler.AppendFormatted(item2.Content);
handler.AppendLiteral(" (사용 ");
handler.AppendFormatted(item2.UseCount);
handler.AppendLiteral("회, ID: ");
handler.AppendFormatted(item2.Id);
handler.AppendLiteral(")");
stringBuilder5.AppendLine(ref handler);
}
}
return ToolResult.Ok(stringBuilder.ToString());
}
private static ToolResult ExecuteDelete(JsonElement args, AgentMemoryService svc)
{
JsonElement value;
string text = (args.TryGetProperty("id", out value) ? (value.GetString() ?? "") : "");
if (string.IsNullOrWhiteSpace(text))
{
return ToolResult.Fail("id가 필요합니다.");
}
return svc.Remove(text) ? ToolResult.Ok("메모리 삭제됨 (ID: " + text + ")") : ToolResult.Fail("해당 ID의 메모리를 찾을 수 없습니다: " + text);
}
}

View File

@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["paths"] = new ToolProperty
{
Type = "array",
Description = "List of file paths to read (max 10)",
Items = new ToolProperty
{
Type = "string",
Description = "File path"
}
},
["max_lines"] = new ToolProperty
{
Type = "integer",
Description = "Max lines per file (default 200)"
}
}
};
int num = 1;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
CollectionsMarshal.AsSpan(list)[0] = "paths";
obj.Required = list;
return obj;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
JsonElement value;
int num = (args.TryGetProperty("max_lines", out value) ? value.GetInt32() : 200);
if (num <= 0)
{
num = 200;
}
if (!args.TryGetProperty("paths", out var value2) || value2.ValueKind != JsonValueKind.Array)
{
return Task.FromResult(ToolResult.Fail("'paths'는 문자열 배열이어야 합니다."));
}
List<string> list = new List<string>();
foreach (JsonElement item in value2.EnumerateArray())
{
string text = item.GetString();
if (!string.IsNullOrEmpty(text))
{
list.Add(text);
}
}
if (list.Count == 0)
{
return Task.FromResult(ToolResult.Fail("읽을 파일이 없습니다."));
}
if (list.Count > 10)
{
return Task.FromResult(ToolResult.Fail("최대 10개 파일만 지원합니다."));
}
StringBuilder stringBuilder = new StringBuilder();
int num2 = 0;
foreach (string item2 in list)
{
string text2 = (Path.IsPathRooted(item2) ? item2 : Path.Combine(context.WorkFolder, item2));
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(8, 1, stringBuilder2);
handler.AppendLiteral("═══ ");
handler.AppendFormatted(Path.GetFileName(text2));
handler.AppendLiteral(" ═══");
stringBuilder3.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(6, 1, stringBuilder2);
handler.AppendLiteral("Path: ");
handler.AppendFormatted(text2);
stringBuilder4.AppendLine(ref handler);
if (!context.IsPathAllowed(text2))
{
stringBuilder.AppendLine("[접근 차단됨]");
}
else if (!File.Exists(text2))
{
stringBuilder.AppendLine("[파일 없음]");
}
else
{
try
{
List<string> list2 = File.ReadLines(text2).Take(num).ToList();
for (int i = 0; i < list2.Count; i++)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(1, 2, stringBuilder2);
handler.AppendFormatted(i + 1);
handler.AppendLiteral("\t");
handler.AppendFormatted(list2[i]);
stringBuilder5.AppendLine(ref handler);
}
if (list2.Count >= num)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(23, 1, stringBuilder2);
handler.AppendLiteral("... (이후 생략, max_lines=");
handler.AppendFormatted(num);
handler.AppendLiteral(")");
stringBuilder6.AppendLine(ref handler);
}
num2++;
}
catch (Exception ex)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("[읽기 오류: ");
handler.AppendFormatted(ex.Message);
handler.AppendLiteral("]");
stringBuilder7.AppendLine(ref handler);
}
}
stringBuilder.AppendLine();
}
return Task.FromResult(ToolResult.Ok($"{num2}/{list.Count}개 파일 읽기 완료.\n\n{stringBuilder}"));
}
}

View File

@@ -0,0 +1,184 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Effects;
using System.Windows.Threading;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> obj = new Dictionary<string, ToolProperty>
{
["title"] = new ToolProperty
{
Type = "string",
Description = "Notification title (short, 1-2 words)"
},
["message"] = new ToolProperty
{
Type = "string",
Description = "Notification message (detail text)"
}
};
ToolProperty obj2 = new ToolProperty
{
Type = "string",
Description = "Notification level: info (default), success, warning, error"
};
int num = 4;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "info";
span[1] = "success";
span[2] = "warning";
span[3] = "error";
obj2.Enum = list;
obj["level"] = obj2;
toolParameterSchema.Properties = obj;
num = 2;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
Span<string> span2 = CollectionsMarshal.AsSpan(list2);
span2[0] = "title";
span2[1] = "message";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string title = args.GetProperty("title").GetString() ?? "알림";
string message = args.GetProperty("message").GetString() ?? "";
JsonElement value;
string level = (args.TryGetProperty("level", out value) ? (value.GetString() ?? "info") : "info");
try
{
((DispatcherObject)Application.Current).Dispatcher.Invoke((Action)delegate
{
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)
{
//IL_0464: Unknown result type (might be due to invalid IL or missing references)
//IL_0469: Unknown result type (might be due to invalid IL or missing references)
//IL_0483: Expected O, but got Unknown
Window mainWindow = Application.Current.MainWindow;
if (mainWindow == null)
{
return;
}
if (1 == 0)
{
}
(string, string) tuple = level switch
{
"success" => ("\ue73e", "#34D399"),
"warning" => ("\ue7ba", "#F59E0B"),
"error" => ("\uea39", "#F87171"),
_ => ("\ue946", "#4B5EFC"),
};
if (1 == 0)
{
}
(string, string) tuple2 = tuple;
string item = tuple2.Item1;
string item2 = tuple2.Item2;
Border toast = new Border
{
Background = new SolidColorBrush(Color.FromRgb(26, 27, 46)),
CornerRadius = new CornerRadius(10.0),
Padding = new Thickness(16.0, 12.0, 16.0, 12.0),
Margin = new Thickness(0.0, 0.0, 20.0, 20.0),
MinWidth = 280.0,
MaxWidth = 400.0,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Bottom,
Effect = new DropShadowEffect
{
BlurRadius = 16.0,
ShadowDepth = 4.0,
Opacity = 0.4,
Color = Colors.Black
}
};
StackPanel stackPanel = new StackPanel();
StackPanel stackPanel2 = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0.0, 0.0, 0.0, 4.0)
};
stackPanel2.Children.Add(new TextBlock
{
Text = item,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 14.0,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(item2)),
Margin = new Thickness(0.0, 0.0, 8.0, 0.0),
VerticalAlignment = VerticalAlignment.Center
});
stackPanel2.Children.Add(new TextBlock
{
Text = title,
FontSize = 13.0,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.White,
VerticalAlignment = VerticalAlignment.Center
});
stackPanel.Children.Add(stackPanel2);
if (!string.IsNullOrEmpty(message))
{
stackPanel.Children.Add(new TextBlock
{
Text = ((message.Length > 200) ? (message.Substring(0, 200) + "...") : message),
FontSize = 12.0,
Foreground = new SolidColorBrush(Color.FromRgb(170, 170, 204)),
TextWrapping = TextWrapping.Wrap
});
}
toast.Child = stackPanel;
object content = mainWindow.Content;
Grid grid = content as Grid;
if (grid != null)
{
Grid.SetRowSpan(toast, (grid.RowDefinitions.Count <= 0) ? 1 : grid.RowDefinitions.Count);
Grid.SetColumnSpan(toast, (grid.ColumnDefinitions.Count <= 0) ? 1 : grid.ColumnDefinitions.Count);
grid.Children.Add(toast);
DispatcherTimer timer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(5.0)
};
timer.Tick += delegate
{
timer.Stop();
grid.Children.Remove(toast);
};
timer.Start();
}
}
}

View File

@@ -0,0 +1,81 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty> { ["path"] = new ToolProperty
{
Type = "string",
Description = "File path, directory path, or URL to open"
} }
};
int num = 1;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
CollectionsMarshal.AsSpan(list)[0] = "path";
obj.Required = list;
return obj;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string text = args.GetProperty("path").GetString() ?? "";
if (string.IsNullOrWhiteSpace(text))
{
return Task.FromResult(ToolResult.Fail("경로가 비어 있습니다."));
}
try
{
if (text.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || text.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
Process.Start(new ProcessStartInfo(text)
{
UseShellExecute = true
});
return Task.FromResult(ToolResult.Ok("URL 열기: " + text));
}
string text2 = (Path.IsPathRooted(text) ? text : Path.Combine(context.WorkFolder, text));
if (!context.IsPathAllowed(text2))
{
return Task.FromResult(ToolResult.Fail("경로 접근 차단: " + text2));
}
if (File.Exists(text2))
{
Process.Start(new ProcessStartInfo(text2)
{
UseShellExecute = true
});
return Task.FromResult(ToolResult.Ok("파일 열기: " + text2, text2));
}
if (Directory.Exists(text2))
{
Process.Start(new ProcessStartInfo("explorer.exe", text2));
return Task.FromResult(ToolResult.Ok("폴더 열기: " + text2, text2));
}
return Task.FromResult(ToolResult.Fail("경로를 찾을 수 없습니다: " + text2));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail("열기 오류: " + ex.Message));
}
}
}

View File

@@ -0,0 +1,419 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public class PlaybookTool : IAgentTool
{
private class PlaybookData
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public List<string> Steps { get; set; } = new List<string>();
public List<string> ToolsUsed { get; set; } = new List<string>();
public string CreatedAt { get; set; } = "";
}
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "save | list | describe | delete"
};
int num = 4;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "save";
span[1] = "list";
span[2] = "describe";
span[3] = "delete";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["name"] = new ToolProperty
{
Type = "string",
Description = "Playbook name (for save)"
};
dictionary["description"] = new ToolProperty
{
Type = "string",
Description = "What this playbook does (for save)"
};
dictionary["steps"] = new ToolProperty
{
Type = "array",
Description = "List of step descriptions (for save)",
Items = new ToolProperty
{
Type = "string",
Description = "Step description"
}
};
dictionary["tools_used"] = new ToolProperty
{
Type = "array",
Description = "List of tool names used in this workflow (for save)",
Items = new ToolProperty
{
Type = "string",
Description = "Tool name"
}
};
dictionary["id"] = new ToolProperty
{
Type = "integer",
Description = "Playbook ID (for describe/delete)"
};
toolParameterSchema.Properties = dictionary;
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "action";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
if (!args.TryGetProperty("action", out var actionEl))
{
return ToolResult.Fail("action이 필요합니다.");
}
string action = actionEl.GetString() ?? "";
if (string.IsNullOrEmpty(context.WorkFolder))
{
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
}
string playbookDir = Path.Combine(context.WorkFolder, ".ax", "playbooks");
if (1 == 0)
{
}
ToolResult result = 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 중 선택하세요."),
};
if (1 == 0)
{
}
return result;
}
private static async Task<ToolResult> SavePlaybook(JsonElement args, string playbookDir, CancellationToken ct)
{
JsonElement n;
string name = (args.TryGetProperty("name", out n) ? (n.GetString() ?? "") : "");
JsonElement d;
string description = (args.TryGetProperty("description", out d) ? (d.GetString() ?? "") : "");
if (string.IsNullOrWhiteSpace(name))
{
return ToolResult.Fail("플레이북 name이 필요합니다.");
}
if (string.IsNullOrWhiteSpace(description))
{
return ToolResult.Fail("플레이북 description이 필요합니다.");
}
List<string> steps = new List<string>();
if (args.TryGetProperty("steps", out var stepsEl) && stepsEl.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in stepsEl.EnumerateArray())
{
string s = item.GetString();
if (!string.IsNullOrWhiteSpace(s))
{
steps.Add(s);
}
}
}
if (steps.Count == 0)
{
return ToolResult.Fail("최소 1개 이상의 step이 필요합니다.");
}
List<string> toolsUsed = new List<string>();
if (args.TryGetProperty("tools_used", out var toolsEl) && toolsEl.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item2 in toolsEl.EnumerateArray())
{
string t = item2.GetString();
if (!string.IsNullOrWhiteSpace(t))
{
toolsUsed.Add(t);
}
}
}
try
{
Directory.CreateDirectory(playbookDir);
int nextId = GetNextId(playbookDir);
string safeName = string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
string fileName = $"{nextId}_{safeName}.json";
PlaybookData playbook = new PlaybookData
{
Id = nextId,
Name = name,
Description = description,
Steps = steps,
ToolsUsed = toolsUsed,
CreatedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
};
string json = JsonSerializer.Serialize(playbook, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
string filePath = Path.Combine(playbookDir, fileName);
await File.WriteAllTextAsync(filePath, json, 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("저장된 플레이북이 없습니다.");
}
List<string> list = (from f in Directory.GetFiles(playbookDir, "*.json")
orderby f
select f).ToList();
if (list.Count == 0)
{
return ToolResult.Ok("저장된 플레이북이 없습니다.");
}
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder2);
handler.AppendLiteral("플레이북 ");
handler.AppendFormatted(list.Count);
handler.AppendLiteral("개:");
stringBuilder3.AppendLine(ref handler);
foreach (string item in list)
{
try
{
string json = File.ReadAllText(item);
PlaybookData playbookData = JsonSerializer.Deserialize<PlaybookData>(json);
if (playbookData != null)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(15, 5, stringBuilder2);
handler.AppendLiteral(" [");
handler.AppendFormatted(playbookData.Id);
handler.AppendLiteral("] ");
handler.AppendFormatted(playbookData.Name);
handler.AppendLiteral(" — ");
handler.AppendFormatted(playbookData.Description);
handler.AppendLiteral(" (");
handler.AppendFormatted(playbookData.Steps.Count);
handler.AppendLiteral("단계, ");
handler.AppendFormatted(playbookData.CreatedAt);
handler.AppendLiteral(")");
stringBuilder4.AppendLine(ref handler);
}
}
catch
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(14, 1, stringBuilder2);
handler.AppendLiteral(" [?] ");
handler.AppendFormatted(Path.GetFileName(item));
handler.AppendLiteral(" — 파싱 오류");
stringBuilder5.AppendLine(ref handler);
}
}
return ToolResult.Ok(stringBuilder.ToString());
}
private static async Task<ToolResult> DescribePlaybook(JsonElement args, string playbookDir, CancellationToken ct)
{
if (!args.TryGetProperty("id", out var idEl))
{
return ToolResult.Fail("플레이북 id가 필요합니다.");
}
int parsed;
int id = ((idEl.ValueKind == JsonValueKind.Number) ? idEl.GetInt32() : (int.TryParse(idEl.GetString(), out parsed) ? parsed : (-1)));
PlaybookData playbook = await FindPlaybookById(playbookDir, id, ct);
if (playbook == null)
{
return ToolResult.Fail($"ID {id}의 플레이북을 찾을 수 없습니다.");
}
StringBuilder sb = new StringBuilder();
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(13, 2, stringBuilder);
handler.AppendLiteral("플레이북: ");
handler.AppendFormatted(playbook.Name);
handler.AppendLiteral(" (ID: ");
handler.AppendFormatted(playbook.Id);
handler.AppendLiteral(")");
stringBuilder2.AppendLine(ref handler);
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 1, stringBuilder);
handler.AppendLiteral("설명: ");
handler.AppendFormatted(playbook.Description);
stringBuilder3.AppendLine(ref handler);
stringBuilder = sb;
StringBuilder stringBuilder4 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(5, 1, stringBuilder);
handler.AppendLiteral("생성일: ");
handler.AppendFormatted(playbook.CreatedAt);
stringBuilder4.AppendLine(ref handler);
sb.AppendLine();
sb.AppendLine("단계:");
for (int i = 0; i < playbook.Steps.Count; i++)
{
stringBuilder = sb;
StringBuilder stringBuilder5 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 2, stringBuilder);
handler.AppendLiteral(" ");
handler.AppendFormatted(i + 1);
handler.AppendLiteral(". ");
handler.AppendFormatted(playbook.Steps[i]);
stringBuilder5.AppendLine(ref handler);
}
if (playbook.ToolsUsed.Count > 0)
{
sb.AppendLine();
stringBuilder = sb;
StringBuilder stringBuilder6 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder);
handler.AppendLiteral("사용 도구: ");
handler.AppendFormatted(string.Join(", ", playbook.ToolsUsed));
stringBuilder6.AppendLine(ref handler);
}
return ToolResult.Ok(sb.ToString());
}
private static ToolResult DeletePlaybook(JsonElement args, string playbookDir)
{
if (!args.TryGetProperty("id", out var value))
{
return ToolResult.Fail("삭제할 플레이북 id가 필요합니다.");
}
int result;
int num = ((value.ValueKind == JsonValueKind.Number) ? value.GetInt32() : (int.TryParse(value.GetString(), out result) ? result : (-1)));
if (!Directory.Exists(playbookDir))
{
return ToolResult.Fail("저장된 플레이북이 없습니다.");
}
string[] files = Directory.GetFiles(playbookDir, $"{num}_*.json");
if (files.Length == 0)
{
string[] files2 = Directory.GetFiles(playbookDir, "*.json");
foreach (string path in files2)
{
try
{
string json = File.ReadAllText(path);
PlaybookData playbookData = JsonSerializer.Deserialize<PlaybookData>(json);
if (playbookData != null && playbookData.Id == num)
{
string name = playbookData.Name;
File.Delete(path);
return ToolResult.Ok($"플레이북 삭제됨: [{num}] {name}");
}
}
catch
{
}
}
return ToolResult.Fail($"ID {num}의 플레이북을 찾을 수 없습니다.");
}
try
{
string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(files[0]);
File.Delete(files[0]);
return ToolResult.Ok("플레이북 삭제됨: " + fileNameWithoutExtension);
}
catch (Exception ex)
{
return ToolResult.Fail("플레이북 삭제 오류: " + ex.Message);
}
}
private static int GetNextId(string playbookDir)
{
if (!Directory.Exists(playbookDir))
{
return 1;
}
int num = 0;
string[] files = Directory.GetFiles(playbookDir, "*.json");
foreach (string path in files)
{
try
{
string json = File.ReadAllText(path);
PlaybookData playbookData = JsonSerializer.Deserialize<PlaybookData>(json);
if (playbookData != null && playbookData.Id > num)
{
num = playbookData.Id;
}
}
catch
{
}
}
return num + 1;
}
private static async Task<PlaybookData?> FindPlaybookById(string playbookDir, int id, CancellationToken ct)
{
if (!Directory.Exists(playbookDir))
{
return null;
}
string[] files = Directory.GetFiles(playbookDir, "*.json");
foreach (string file in files)
{
try
{
PlaybookData pb = JsonSerializer.Deserialize<PlaybookData>(await File.ReadAllTextAsync(file, ct));
if (pb != null && pb.Id == id)
{
return pb;
}
}
catch
{
}
}
return null;
}
}

View File

@@ -0,0 +1,333 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Drawing;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Presentation;
namespace AxCopilot.Services.Agent;
public class PptxSkill : IAgentTool
{
private static readonly Dictionary<string, (string Primary, string Accent, string TextDark, string TextLight, string Bg)> Themes = new Dictionary<string, (string, string, string, string, string)>
{
["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 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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> obj = new Dictionary<string, ToolProperty>
{
["path"] = new ToolProperty
{
Type = "string",
Description = "Output file path (.pptx). Relative to work folder."
},
["title"] = new ToolProperty
{
Type = "string",
Description = "Presentation title (used on first slide if no explicit title slide)."
},
["slides"] = new ToolProperty
{
Type = "array",
Description = "Array of slide objects. Each slide: {\"layout\": \"title|content|two_column|table|blank\", \"title\": \"Slide Title\", \"subtitle\": \"...\", \"body\": \"...\", \"left\": \"...\", \"right\": \"...\", \"headers\": [...], \"rows\": [[...]], \"notes\": \"Speaker notes\"}",
Items = new ToolProperty
{
Type = "object"
}
}
};
ToolProperty obj2 = new ToolProperty
{
Type = "string",
Description = "Color theme: professional (blue), modern (teal), dark (dark gray), minimal (light). Default: professional"
};
int num = 4;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "professional";
span[1] = "modern";
span[2] = "dark";
span[3] = "minimal";
obj2.Enum = list;
obj["theme"] = obj2;
toolParameterSchema.Properties = obj;
num = 2;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
Span<string> span2 = CollectionsMarshal.AsSpan(list2);
span2[0] = "path";
span2[1] = "slides";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string path = args.GetProperty("path").GetString() ?? "";
if (!args.TryGetProperty("title", out var tt))
{
}
else if (tt.GetString() == null)
{
}
JsonElement th;
string theme = (args.TryGetProperty("theme", out th) ? (th.GetString() ?? "professional") : "professional");
if (!args.TryGetProperty("slides", out var slidesEl) || slidesEl.ValueKind != JsonValueKind.Array)
{
return ToolResult.Fail("slides 배열이 필요합니다.");
}
string 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);
}
string dir = System.IO.Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
if (!Themes.TryGetValue(theme, out var colors))
{
colors = Themes["professional"];
}
try
{
using PresentationDocument pres = PresentationDocument.Create(fullPath, PresentationDocumentType.Presentation);
PresentationPart presPart = pres.AddPresentationPart();
presPart.Presentation = new Presentation();
presPart.Presentation.SlideIdList = new SlideIdList();
presPart.Presentation.SlideSize = new SlideSize
{
Cx = 12192000,
Cy = 6858000
};
presPart.Presentation.NotesSize = new NotesSize
{
Cx = 6858000L,
Cy = 9144000L
};
uint slideId = 256u;
int slideCount = 0;
foreach (JsonElement slideEl in slidesEl.EnumerateArray())
{
JsonElement lay;
string layout = (slideEl.TryGetProperty("layout", out lay) ? (lay.GetString() ?? "content") : "content");
JsonElement st;
string slideTitle = (slideEl.TryGetProperty("title", out st) ? (st.GetString() ?? "") : "");
JsonElement sub;
string subtitle = (slideEl.TryGetProperty("subtitle", out sub) ? (sub.GetString() ?? "") : "");
JsonElement bd;
string body = (slideEl.TryGetProperty("body", out bd) ? (bd.GetString() ?? "") : "");
JsonElement lf;
string left = (slideEl.TryGetProperty("left", out lf) ? (lf.GetString() ?? "") : "");
JsonElement rt;
string right = (slideEl.TryGetProperty("right", out rt) ? (rt.GetString() ?? "") : "");
if (!slideEl.TryGetProperty("notes", out var nt))
{
}
else if (nt.GetString() == null)
{
}
SlidePart slidePart = presPart.AddNewPart<SlidePart>();
slidePart.Slide = new Slide(new CommonSlideData(new ShapeTree(new DocumentFormat.OpenXml.Presentation.NonVisualGroupShapeProperties(new DocumentFormat.OpenXml.Presentation.NonVisualDrawingProperties
{
Id = 1u,
Name = ""
}, new DocumentFormat.OpenXml.Presentation.NonVisualGroupShapeDrawingProperties(), new ApplicationNonVisualDrawingProperties()), new GroupShapeProperties(new TransformGroup()))));
ShapeTree shapeTree = slidePart.Slide.CommonSlideData.ShapeTree;
uint shapeId = 2u;
switch (layout)
{
case "title":
AddRectangle(shapeTree, ref shapeId, 0L, 0L, 12192000L, 6858000L, colors.Primary);
AddTextBox(shapeTree, ref shapeId, 600000L, 2000000L, 10992000L, 1400000L, slideTitle, 3600, colors.TextLight, bold: true);
if (!string.IsNullOrEmpty(subtitle))
{
AddTextBox(shapeTree, ref shapeId, 600000L, 3600000L, 10992000L, 800000L, subtitle, 2000, colors.TextLight, bold: false);
}
break;
case "two_column":
AddTextBox(shapeTree, ref shapeId, 600000L, 300000L, 10992000L, 800000L, slideTitle, 2800, colors.Primary, bold: true);
AddTextBox(shapeTree, ref shapeId, 600000L, 1300000L, 5200000L, 5000000L, left, 1600, colors.TextDark, bold: false);
AddTextBox(shapeTree, ref shapeId, 6400000L, 1300000L, 5200000L, 5000000L, right, 1600, colors.TextDark, bold: false);
break;
case "table":
{
AddTextBox(shapeTree, ref shapeId, 600000L, 300000L, 10992000L, 800000L, slideTitle, 2800, colors.Primary, bold: true);
string tableText = FormatTableAsText(slideEl, colors);
AddTextBox(shapeTree, ref shapeId, 600000L, 1300000L, 10992000L, 5000000L, tableText, 1400, colors.TextDark, bold: false);
break;
}
default:
AddTextBox(shapeTree, ref shapeId, 600000L, 300000L, 10992000L, 800000L, slideTitle, 2800, colors.Primary, bold: true);
AddTextBox(shapeTree, ref shapeId, 600000L, 1300000L, 10992000L, 5000000L, body, 1600, colors.TextDark, bold: false);
break;
case "blank":
break;
}
presPart.Presentation.SlideIdList.AppendChild(new SlideId
{
Id = slideId++,
RelationshipId = presPart.GetIdOfPart(slidePart)
});
slideCount++;
lay = default(JsonElement);
st = default(JsonElement);
sub = default(JsonElement);
bd = default(JsonElement);
lf = default(JsonElement);
rt = default(JsonElement);
nt = default(JsonElement);
}
presPart.Presentation.Save();
return ToolResult.Ok($"✅ PPTX 생성 완료: {System.IO.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)
{
DocumentFormat.OpenXml.Presentation.Shape shape = new DocumentFormat.OpenXml.Presentation.Shape();
shape.NonVisualShapeProperties = new DocumentFormat.OpenXml.Presentation.NonVisualShapeProperties(new DocumentFormat.OpenXml.Presentation.NonVisualDrawingProperties
{
Id = id++,
Name = $"TextBox{id}"
}, new DocumentFormat.OpenXml.Presentation.NonVisualShapeDrawingProperties(new ShapeLocks
{
NoGrouping = true
}), new ApplicationNonVisualDrawingProperties());
shape.ShapeProperties = new DocumentFormat.OpenXml.Presentation.ShapeProperties(new Transform2D(new Offset
{
X = x,
Y = y
}, new Extents
{
Cx = cx,
Cy = cy
}), new PresetGeometry(new AdjustValueList())
{
Preset = ShapeTypeValues.Rectangle
});
DocumentFormat.OpenXml.Presentation.TextBody textBody = new DocumentFormat.OpenXml.Presentation.TextBody(new BodyProperties
{
Wrap = TextWrappingValues.Square
}, new ListStyle());
string[] array = text.Split('\n');
string[] array2 = array;
foreach (string text2 in array2)
{
Paragraph paragraph = new Paragraph();
Run run = new Run();
RunProperties runProperties = new RunProperties
{
Language = "ko-KR",
FontSize = fontSize,
Dirty = false
};
runProperties.AppendChild(new SolidFill(new RgbColorModelHex
{
Val = color
}));
if (bold)
{
runProperties.Bold = true;
}
run.AppendChild(runProperties);
run.AppendChild(new DocumentFormat.OpenXml.Drawing.Text(text2));
paragraph.AppendChild(run);
textBody.AppendChild(paragraph);
}
shape.TextBody = textBody;
tree.AppendChild(shape);
}
private static void AddRectangle(ShapeTree tree, ref uint id, long x, long y, long cx, long cy, string fillColor)
{
DocumentFormat.OpenXml.Presentation.Shape shape = new DocumentFormat.OpenXml.Presentation.Shape();
shape.NonVisualShapeProperties = new DocumentFormat.OpenXml.Presentation.NonVisualShapeProperties(new DocumentFormat.OpenXml.Presentation.NonVisualDrawingProperties
{
Id = id++,
Name = $"Rect{id}"
}, new DocumentFormat.OpenXml.Presentation.NonVisualShapeDrawingProperties(), new ApplicationNonVisualDrawingProperties());
shape.ShapeProperties = new DocumentFormat.OpenXml.Presentation.ShapeProperties(new Transform2D(new Offset
{
X = x,
Y = y
}, new Extents
{
Cx = cx,
Cy = cy
}), new PresetGeometry(new AdjustValueList())
{
Preset = ShapeTypeValues.Rectangle
}, new SolidFill(new RgbColorModelHex
{
Val = fillColor
}));
tree.AppendChild(shape);
}
private static string FormatTableAsText(JsonElement slideEl, (string Primary, string Accent, string TextDark, string TextLight, string Bg) colors)
{
StringBuilder stringBuilder = new StringBuilder();
if (slideEl.TryGetProperty("headers", out var value))
{
List<string> list = new List<string>();
foreach (JsonElement item in value.EnumerateArray())
{
list.Add(item.GetString() ?? "");
}
stringBuilder.AppendLine(string.Join(" | ", list));
stringBuilder.AppendLine(new string('─', list.Sum((string h) => h.Length + 5)));
}
if (slideEl.TryGetProperty("rows", out var value2))
{
foreach (JsonElement item2 in value2.EnumerateArray())
{
List<string> list2 = new List<string>();
foreach (JsonElement item3 in item2.EnumerateArray())
{
list2.Add(item3.GetString() ?? item3.ToString());
}
stringBuilder.AppendLine(string.Join(" | ", list2));
}
}
return stringBuilder.ToString();
}
}

View File

@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public class ProcessTool : IAgentTool
{
private static readonly string[] DangerousPatterns = new string[16]
{
"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 string Name => "process";
public string Description => "Execute a shell command (cmd or powershell). Returns stdout and stderr. Has a timeout limit.";
public ToolParameterSchema Parameters
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> obj = new Dictionary<string, ToolProperty> { ["command"] = new ToolProperty
{
Type = "string",
Description = "Command to execute"
} };
ToolProperty obj2 = new ToolProperty
{
Type = "string",
Description = "Shell to use: 'cmd' or 'powershell'. Default: 'cmd'."
};
int num = 2;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "cmd";
span[1] = "powershell";
obj2.Enum = list;
obj["shell"] = obj2;
obj["timeout"] = new ToolProperty
{
Type = "integer",
Description = "Timeout in seconds. Default: 30, max: 120."
};
toolParameterSchema.Properties = obj;
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "command";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
if (!args.TryGetProperty("command", out var cmdEl))
{
return ToolResult.Fail("command가 필요합니다.");
}
string command = cmdEl.GetString() ?? "";
JsonElement sh;
string shell = (args.TryGetProperty("shell", out sh) ? (sh.GetString() ?? "cmd") : "cmd");
JsonElement to;
int timeout = (args.TryGetProperty("timeout", out to) ? Math.Min(to.GetInt32(), 120) : 30);
if (string.IsNullOrWhiteSpace(command))
{
return ToolResult.Fail("명령이 비어 있습니다.");
}
string[] dangerousPatterns = DangerousPatterns;
foreach (string pattern in dangerousPatterns)
{
if (command.Contains(pattern, StringComparison.OrdinalIgnoreCase))
{
return ToolResult.Fail("위험 명령 차단: '" + pattern + "' 패턴이 감지되었습니다.");
}
}
if (!(await context.CheckWritePermissionAsync(Name, command)))
{
return ToolResult.Fail("명령 실행 권한 거부");
}
try
{
string arguments;
string fileName;
if (!(shell == "powershell"))
{
string text = "/C " + command;
arguments = text;
fileName = "cmd.exe";
}
else
{
string text = "-NoProfile -NonInteractive -Command \"" + command.Replace("\"", "\\\"") + "\"";
arguments = text;
fileName = "powershell.exe";
}
ProcessStartInfo 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 Process process = new Process
{
StartInfo = psi
};
StringBuilder stdout = new StringBuilder();
StringBuilder stderr = new StringBuilder();
process.OutputDataReceived += delegate(object _, DataReceivedEventArgs e)
{
if (e.Data != null)
{
stdout.AppendLine(e.Data);
}
};
process.ErrorDataReceived += delegate(object _, DataReceivedEventArgs e)
{
if (e.Data != null)
{
stderr.AppendLine(e.Data);
}
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using CancellationTokenSource 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}초 초과)");
}
string output = stdout.ToString().TrimEnd();
string error = stderr.ToString().TrimEnd();
if (output.Length > 8000)
{
output = output.Substring(0, 8000) + "\n... (출력 잘림)";
}
StringBuilder result = new StringBuilder();
StringBuilder stringBuilder = result;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(13, 1, stringBuilder);
handler.AppendLiteral("[Exit code: ");
handler.AppendFormatted(process.ExitCode);
handler.AppendLiteral("]");
stringBuilder2.AppendLine(ref handler);
if (!string.IsNullOrEmpty(output))
{
result.AppendLine(output);
}
if (!string.IsNullOrEmpty(error))
{
stringBuilder = result;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder);
handler.AppendLiteral("[stderr]\n");
handler.AppendFormatted(error);
stringBuilder3.AppendLine(ref handler);
}
return (process.ExitCode == 0) ? ToolResult.Ok(result.ToString()) : ToolResult.Ok(result.ToString());
}
catch (Exception ex2)
{
return ToolResult.Fail("명령 실행 실패: " + ex2.Message);
}
}
}

View File

@@ -0,0 +1,316 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public class ProjectRuleTool : IAgentTool
{
public string Name => "project_rules";
public string Description => "프로젝트 개발 지침(AX.md) 및 규칙(.ax/rules/)을 관리합니다.\n- read: 현재 AX.md 내용을 읽습니다\n- append: 새 규칙/지침을 AX.md에 추가합니다 (사용자 승인 필요)\n- write: AX.md를 새 내용으로 덮어씁니다 (사용자 승인 필요)\n- list_rules: .ax/rules/ 디렉토리의 프로젝트 규칙 파일 목록을 조회합니다\n- read_rule: .ax/rules/ 디렉토리의 특정 규칙 파일을 읽습니다\n사용자가 '개발 지침에 추가해', '규칙을 저장해', 'AX.md에 기록해' 등을 요청하면 이 도구를 사용하세요.";
public ToolParameterSchema Parameters
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "read (AX.md 읽기), append (추가), write (전체 덮어쓰기), list_rules (.ax/rules/ 목록), read_rule (규칙 파일 읽기)"
};
int num = 5;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "read";
span[1] = "append";
span[2] = "write";
span[3] = "list_rules";
span[4] = "read_rule";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["rule_name"] = new ToolProperty
{
Type = "string",
Description = "read_rule 시 읽을 규칙 파일 이름 (확장자 제외). 예: 'coding-conventions'"
};
dictionary["content"] = new ToolProperty
{
Type = "string",
Description = "append/write 시 저장할 내용. 마크다운 형식을 권장합니다."
};
dictionary["section"] = new ToolProperty
{
Type = "string",
Description = "append 시 섹션 제목. 예: '코딩 컨벤션', '빌드 규칙'. 비어있으면 파일 끝에 추가."
};
toolParameterSchema.Properties = dictionary;
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "action";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
JsonElement a;
string action = (args.TryGetProperty("action", out a) ? (a.GetString() ?? "") : "");
JsonElement c;
string content = (args.TryGetProperty("content", out c) ? (c.GetString() ?? "") : "");
JsonElement s;
string section = (args.TryGetProperty("section", out s) ? (s.GetString() ?? "") : "");
JsonElement rn;
string ruleName = (args.TryGetProperty("rule_name", out rn) ? (rn.GetString() ?? "") : "");
if (string.IsNullOrEmpty(context.WorkFolder))
{
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
}
string axMdPath = FindAxMd(context.WorkFolder) ?? Path.Combine(context.WorkFolder, "AX.md");
if (1 == 0)
{
}
ToolResult result = 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 중 선택하세요."),
};
if (1 == 0)
{
}
return result;
}
private static ToolResult ReadAxMd(string path)
{
if (!File.Exists(path))
{
return ToolResult.Ok("AX.md 파일이 없습니다.\n경로: " + path + "\n\n새로 생성하려면 append 또는 write 액션을 사용하세요.");
}
try
{
string text = File.ReadAllText(path, Encoding.UTF8);
if (string.IsNullOrWhiteSpace(text))
{
return ToolResult.Ok("AX.md 파일이 비어 있습니다.");
}
return ToolResult.Ok($"[AX.md 내용 ({text.Length}자)]\n\n{text}");
}
catch (Exception ex)
{
return ToolResult.Fail("AX.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가 필요합니다.");
}
string desc = "AX.md에 개발 지침을 추가합니다:\n" + ((content.Length > 200) ? (content.Substring(0, 200) + "...") : content);
if (!(await context.CheckWritePermissionAsync("project_rules", desc)))
{
return ToolResult.Ok("사용자가 AX.md 수정을 거부했습니다.");
}
try
{
StringBuilder 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))
{
StringBuilder stringBuilder = sb;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder);
handler.AppendLiteral("## ");
handler.AppendFormatted(section);
stringBuilder.AppendLine(ref handler);
}
sb.AppendLine(content.Trim());
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
return ToolResult.Ok($"AX.md에 개발 지침이 추가되었습니다.\n경로: {path}\n추가된 내용 ({content.Length}자):\n{content}", path);
}
catch (Exception ex)
{
return ToolResult.Fail("AX.md 쓰기 실패: " + ex.Message);
}
}
private static async Task<ToolResult> WriteAxMdAsync(string path, string content, AgentContext context)
{
if (string.IsNullOrWhiteSpace(content))
{
return ToolResult.Fail("저장할 content가 필요합니다.");
}
string desc = $"AX.md를 전체 덮어씁니다 ({content.Length}자):\n{((content.Length > 200) ? (content.Substring(0, 200) + "...") : content)}";
if (!(await context.CheckWritePermissionAsync("project_rules", desc)))
{
return ToolResult.Ok("사용자가 AX.md 수정을 거부했습니다.");
}
try
{
File.WriteAllText(path, content, Encoding.UTF8);
return ToolResult.Ok($"AX.md가 저장되었습니다.\n경로: {path}\n내용 ({content.Length}자)", path);
}
catch (Exception ex)
{
return ToolResult.Fail("AX.md 쓰기 실패: " + ex.Message);
}
}
private static ToolResult ListRules(string workFolder)
{
List<ProjectRulesService.ProjectRule> list = ProjectRulesService.LoadRules(workFolder);
if (list.Count == 0)
{
string text = ProjectRulesService.FindRulesDirectory(workFolder);
string text2 = text ?? Path.Combine(workFolder, ".ax", "rules");
return ToolResult.Ok("프로젝트 규칙이 없습니다.\n규칙 파일을 추가하려면 " + text2 + " 디렉토리에 .md 파일을 생성하세요.\n\n예시 규칙 파일 형식:\n---\nname: 코딩 컨벤션\ndescription: C# 코딩 규칙\napplies-to: \"*.cs\"\nwhen: always\n---\n\n규칙 내용...");
}
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(12, 1, stringBuilder2);
handler.AppendLiteral("[프로젝트 규칙 ");
handler.AppendFormatted(list.Count);
handler.AppendLiteral("개]\n");
stringBuilder3.AppendLine(ref handler);
foreach (ProjectRulesService.ProjectRule item in list)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 1, stringBuilder2);
handler.AppendLiteral(" • ");
handler.AppendFormatted(item.Name);
stringBuilder4.AppendLine(ref handler);
if (!string.IsNullOrEmpty(item.Description))
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(8, 1, stringBuilder2);
handler.AppendLiteral(" 설명: ");
handler.AppendFormatted(item.Description);
stringBuilder5.AppendLine(ref handler);
}
if (!string.IsNullOrEmpty(item.AppliesTo))
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder2);
handler.AppendLiteral(" 적용 대상: ");
handler.AppendFormatted(item.AppliesTo);
stringBuilder6.AppendLine(ref handler);
}
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder2);
handler.AppendLiteral(" 적용 시점: ");
handler.AppendFormatted(item.When);
stringBuilder7.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder8 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(8, 1, stringBuilder2);
handler.AppendLiteral(" 파일: ");
handler.AppendFormatted(item.FilePath);
stringBuilder8.AppendLine(ref handler);
stringBuilder.AppendLine();
}
return ToolResult.Ok(stringBuilder.ToString());
}
private static ToolResult ReadRule(string workFolder, string ruleName)
{
if (string.IsNullOrWhiteSpace(ruleName))
{
return ToolResult.Fail("rule_name 파라미터가 필요합니다.");
}
List<ProjectRulesService.ProjectRule> source = ProjectRulesService.LoadRules(workFolder);
ProjectRulesService.ProjectRule projectRule = source.FirstOrDefault((ProjectRulesService.ProjectRule r) => r.Name.Equals(ruleName, StringComparison.OrdinalIgnoreCase) || Path.GetFileNameWithoutExtension(r.FilePath).Equals(ruleName, StringComparison.OrdinalIgnoreCase));
if (projectRule == null)
{
return ToolResult.Fail("규칙 '" + ruleName + "'을(를) 찾을 수 없습니다. list_rules로 목록을 확인하세요.");
}
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(6, 1, stringBuilder2);
handler.AppendLiteral("[규칙: ");
handler.AppendFormatted(projectRule.Name);
handler.AppendLiteral("]");
stringBuilder3.AppendLine(ref handler);
if (!string.IsNullOrEmpty(projectRule.Description))
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 1, stringBuilder2);
handler.AppendLiteral("설명: ");
handler.AppendFormatted(projectRule.Description);
stringBuilder4.AppendLine(ref handler);
}
if (!string.IsNullOrEmpty(projectRule.AppliesTo))
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder2);
handler.AppendLiteral("적용 대상: ");
handler.AppendFormatted(projectRule.AppliesTo);
stringBuilder5.AppendLine(ref handler);
}
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder2);
handler.AppendLiteral("적용 시점: ");
handler.AppendFormatted(projectRule.When);
stringBuilder6.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 1, stringBuilder2);
handler.AppendLiteral("파일: ");
handler.AppendFormatted(projectRule.FilePath);
stringBuilder7.AppendLine(ref handler);
stringBuilder.AppendLine();
stringBuilder.AppendLine(projectRule.Body);
return ToolResult.Ok(stringBuilder.ToString(), projectRule.FilePath);
}
private static string? FindAxMd(string workFolder)
{
string text = workFolder;
for (int i = 0; i < 3; i++)
{
if (string.IsNullOrEmpty(text))
{
break;
}
string text2 = Path.Combine(text, "AX.md");
if (File.Exists(text2))
{
return text2;
}
text = Directory.GetParent(text)?.FullName;
}
return null;
}
}

View File

@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
public static class ProjectRulesService
{
public class ProjectRule
{
public string FilePath { get; set; } = "";
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public string AppliesTo { get; set; } = "";
public string When { get; set; } = "always";
public string Body { get; set; } = "";
}
private static readonly Regex FrontMatterRegex = new Regex("^---\\s*\\n(.*?)\\n---\\s*\\n", RegexOptions.Compiled | RegexOptions.Singleline);
private static readonly Regex YamlKeyValue = new Regex("^\\s*(\\w[\\w-]*)\\s*:\\s*(.+?)\\s*$", RegexOptions.Multiline | RegexOptions.Compiled);
public static List<ProjectRule> LoadRules(string workFolder)
{
List<ProjectRule> list = new List<ProjectRule>();
if (string.IsNullOrEmpty(workFolder))
{
return list;
}
string text = FindRulesDirectory(workFolder);
if (text == null)
{
return list;
}
try
{
string[] files = Directory.GetFiles(text, "*.md");
foreach (string filePath in files)
{
ProjectRule projectRule = ParseRuleFile(filePath);
if (projectRule != null)
{
list.Add(projectRule);
}
}
}
catch
{
}
return list;
}
public static List<ProjectRule> FilterRules(List<ProjectRule> rules, string when = "always", IEnumerable<string>? filePaths = null)
{
List<ProjectRule> list = new List<ProjectRule>();
foreach (ProjectRule rule in rules)
{
string text = rule.When.ToLowerInvariant().Trim();
if (text != "always" && text != when.ToLowerInvariant())
{
continue;
}
if (!string.IsNullOrEmpty(rule.AppliesTo) && filePaths != null)
{
string pattern = rule.AppliesTo.Trim();
if (!filePaths.Any((string fp) => MatchesGlob(fp, pattern)))
{
continue;
}
}
list.Add(rule);
}
return list;
}
public static string FormatForSystemPrompt(List<ProjectRule> rules)
{
if (rules.Count == 0)
{
return "";
}
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("\n## 프로젝트 규칙 (.ax/rules/)");
stringBuilder.AppendLine("아래 규칙을 반드시 준수하세요:\n");
foreach (ProjectRule rule in rules)
{
if (!string.IsNullOrEmpty(rule.Name))
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(4, 1, stringBuilder2);
handler.AppendLiteral("### ");
handler.AppendFormatted(rule.Name);
stringBuilder3.AppendLine(ref handler);
}
if (!string.IsNullOrEmpty(rule.Description))
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2);
handler.AppendLiteral("*");
handler.AppendFormatted(rule.Description);
handler.AppendLiteral("*\n");
stringBuilder4.AppendLine(ref handler);
}
stringBuilder.AppendLine(rule.Body.Trim());
stringBuilder.AppendLine();
}
return stringBuilder.ToString();
}
internal static string? FindRulesDirectory(string workFolder)
{
string text = workFolder;
for (int i = 0; i < 3; i++)
{
if (string.IsNullOrEmpty(text))
{
break;
}
string text2 = Path.Combine(text, ".ax", "rules");
if (Directory.Exists(text2))
{
return text2;
}
text = Directory.GetParent(text)?.FullName;
}
return null;
}
internal static ProjectRule? ParseRuleFile(string filePath)
{
try
{
string text = File.ReadAllText(filePath, Encoding.UTF8);
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
ProjectRule projectRule = new ProjectRule
{
FilePath = filePath,
Name = Path.GetFileNameWithoutExtension(filePath)
};
Match match = FrontMatterRegex.Match(text);
if (match.Success)
{
string value = match.Groups[1].Value;
foreach (Match item in YamlKeyValue.Matches(value))
{
string text2 = item.Groups[1].Value.ToLowerInvariant();
string text3 = item.Groups[2].Value.Trim().Trim('"', '\'');
switch (text2)
{
case "name":
projectRule.Name = text3;
break;
case "description":
projectRule.Description = text3;
break;
case "applies-to":
case "appliesto":
projectRule.AppliesTo = text3;
break;
case "when":
projectRule.When = text3;
break;
}
}
string text4 = text;
int num = match.Index + match.Length;
projectRule.Body = text4.Substring(num, text4.Length - num);
}
else
{
projectRule.Body = text;
}
return string.IsNullOrWhiteSpace(projectRule.Body) ? null : projectRule;
}
catch
{
return null;
}
}
private static bool MatchesGlob(string path, string pattern)
{
if (pattern.StartsWith("*."))
{
string text = pattern;
return path.EndsWith(text.Substring(1, text.Length - 1), StringComparison.OrdinalIgnoreCase);
}
if (pattern.StartsWith("**/"))
{
string text = pattern;
string pattern2 = text.Substring(3, text.Length - 3);
return MatchesGlob(Path.GetFileName(path), pattern2);
}
return Path.GetFileName(path).Equals(pattern, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,297 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public class RegexTool : IAgentTool
{
private const int MaxTimeout = 5000;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Action to perform"
};
int num = 5;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "test";
span[1] = "match";
span[2] = "replace";
span[3] = "split";
span[4] = "extract";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["pattern"] = new ToolProperty
{
Type = "string",
Description = "Regular expression pattern"
};
dictionary["text"] = new ToolProperty
{
Type = "string",
Description = "Text to process"
};
dictionary["replacement"] = new ToolProperty
{
Type = "string",
Description = "Replacement string for replace action (supports $1, $2, ${name})"
};
dictionary["flags"] = new ToolProperty
{
Type = "string",
Description = "Regex flags: 'i' (ignore case), 'm' (multiline), 's' (singleline). Combine: 'im'"
};
toolParameterSchema.Properties = dictionary;
num = 3;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
Span<string> span2 = CollectionsMarshal.AsSpan(list2);
span2[0] = "action";
span2[1] = "pattern";
span2[2] = "text";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string text = args.GetProperty("action").GetString() ?? "";
string pattern = args.GetProperty("pattern").GetString() ?? "";
string text2 = args.GetProperty("text").GetString() ?? "";
JsonElement value;
string replacement = (args.TryGetProperty("replacement", out value) ? (value.GetString() ?? "") : "");
JsonElement value2;
string flags = (args.TryGetProperty("flags", out value2) ? (value2.GetString() ?? "") : "");
try
{
RegexOptions options = ParseFlags(flags);
Regex regex = new Regex(pattern, options, TimeSpan.FromMilliseconds(5000.0));
if (1 == 0)
{
}
ToolResult result = text switch
{
"test" => Test(regex, text2),
"match" => Match(regex, text2),
"replace" => Replace(regex, text2, replacement),
"split" => Split(regex, text2),
"extract" => Extract(regex, text2),
_ => ToolResult.Fail("Unknown action: " + text),
};
if (1 == 0)
{
}
return Task.FromResult(result);
}
catch (RegexMatchTimeoutException)
{
return Task.FromResult(ToolResult.Fail("정규식 실행 시간 초과 (ReDoS 방지). 패턴을 간소화하세요."));
}
catch (Exception ex2)
{
return Task.FromResult(ToolResult.Fail("정규식 오류: " + ex2.Message));
}
}
private static RegexOptions ParseFlags(string flags)
{
RegexOptions regexOptions = RegexOptions.None;
foreach (char c in flags)
{
RegexOptions regexOptions2 = regexOptions;
if (1 == 0)
{
}
RegexOptions regexOptions3 = c switch
{
'i' => RegexOptions.IgnoreCase,
'm' => RegexOptions.Multiline,
's' => RegexOptions.Singleline,
_ => RegexOptions.None,
};
if (1 == 0)
{
}
regexOptions = regexOptions2 | regexOptions3;
}
return regexOptions;
}
private static ToolResult Test(Regex regex, string text)
{
return ToolResult.Ok(regex.IsMatch(text) ? "✓ Pattern matches" : "✗ No match");
}
private static ToolResult Match(Regex regex, string text)
{
MatchCollection matchCollection = regex.Matches(text);
if (matchCollection.Count == 0)
{
return ToolResult.Ok("No matches found.");
}
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(17, 1, stringBuilder2);
handler.AppendLiteral("Found ");
handler.AppendFormatted(matchCollection.Count);
handler.AppendLiteral(" match(es):");
stringBuilder3.AppendLine(ref handler);
int num = Math.Min(matchCollection.Count, 50);
for (int i = 0; i < num; i++)
{
Match match = matchCollection[i];
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(24, 4, stringBuilder2);
handler.AppendLiteral("\n[");
handler.AppendFormatted(i);
handler.AppendLiteral("] \"");
handler.AppendFormatted(Truncate(match.Value, 200));
handler.AppendLiteral("\" (index ");
handler.AppendFormatted(match.Index);
handler.AppendLiteral(", length ");
handler.AppendFormatted(match.Length);
handler.AppendLiteral(")");
stringBuilder4.AppendLine(ref handler);
if (match.Groups.Count > 1)
{
for (int j = 1; j < match.Groups.Count; j++)
{
Group obj = match.Groups[j];
string text2 = regex.GroupNameFromNumber(j);
string value = ((text2 != j.ToString()) ? ("'" + text2 + "'") : $"${j}");
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(14, 2, stringBuilder2);
handler.AppendLiteral(" Group ");
handler.AppendFormatted(value);
handler.AppendLiteral(": \"");
handler.AppendFormatted(Truncate(obj.Value, 100));
handler.AppendLiteral("\"");
stringBuilder5.AppendLine(ref handler);
}
}
}
if (matchCollection.Count > num)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(22, 1, stringBuilder2);
handler.AppendLiteral("\n... and ");
handler.AppendFormatted(matchCollection.Count - num);
handler.AppendLiteral(" more matches");
stringBuilder6.AppendLine(ref handler);
}
return ToolResult.Ok(stringBuilder.ToString());
}
private static ToolResult Replace(Regex regex, string text, string replacement)
{
string text2 = regex.Replace(text, replacement);
int count = regex.Matches(text).Count;
if (text2.Length > 8000)
{
text2 = text2.Substring(0, 8000) + "\n... (truncated)";
}
return ToolResult.Ok($"Replaced {count} occurrence(s):\n\n{text2}");
}
private static ToolResult Split(Regex regex, string text)
{
string[] array = regex.Split(text);
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(18, 1, stringBuilder2);
handler.AppendLiteral("Split into ");
handler.AppendFormatted(array.Length);
handler.AppendLiteral(" parts:");
stringBuilder3.AppendLine(ref handler);
int num = Math.Min(array.Length, 100);
for (int i = 0; i < num; i++)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(7, 2, stringBuilder2);
handler.AppendLiteral(" [");
handler.AppendFormatted(i);
handler.AppendLiteral("] \"");
handler.AppendFormatted(Truncate(array[i], 200));
handler.AppendLiteral("\"");
stringBuilder4.AppendLine(ref handler);
}
if (array.Length > num)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(20, 1, stringBuilder2);
handler.AppendLiteral("\n... and ");
handler.AppendFormatted(array.Length - num);
handler.AppendLiteral(" more parts");
stringBuilder5.AppendLine(ref handler);
}
return ToolResult.Ok(stringBuilder.ToString());
}
private static ToolResult Extract(Regex regex, string text)
{
Match match = regex.Match(text);
if (!match.Success)
{
return ToolResult.Ok("No match found.");
}
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("Match: \"");
handler.AppendFormatted(Truncate(match.Value, 300));
handler.AppendLiteral("\"");
stringBuilder3.AppendLine(ref handler);
if (match.Groups.Count > 1)
{
stringBuilder.AppendLine("\nGroups:");
for (int i = 1; i < match.Groups.Count; i++)
{
Group obj = match.Groups[i];
string text2 = regex.GroupNameFromNumber(i);
string value = ((text2 != i.ToString()) ? ("'" + text2 + "'") : $"${i}");
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(6, 2, stringBuilder2);
handler.AppendLiteral(" ");
handler.AppendFormatted(value);
handler.AppendLiteral(": \"");
handler.AppendFormatted(Truncate(obj.Value, 200));
handler.AppendLiteral("\"");
stringBuilder4.AppendLine(ref handler);
}
}
return ToolResult.Ok(stringBuilder.ToString());
}
private static string Truncate(string s, int maxLen)
{
return (s.Length <= maxLen) ? s : (s.Substring(0, maxLen) + "…");
}
}

View File

@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AxCopilot.Services.Agent;
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; } = "";
public string License { get; init; } = "";
public string Compatibility { get; init; } = "";
public string AllowedTools { get; init; } = "";
public string Requires { get; init; } = "";
public string Tabs { get; init; } = "all";
public bool IsAvailable { get; set; } = true;
public bool IsStandardFormat => FilePath.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase);
public string UnavailableHint
{
get
{
if (IsAvailable || string.IsNullOrEmpty(Requires))
{
return "";
}
IEnumerable<string> source = from r in Requires.Split(',')
select r.Trim();
string[] array = source.Where((string r) => !RuntimeDetector.IsAvailable(r)).ToArray();
return (array.Length != 0) ? ("(" + string.Join(", ", array.Select((string r) => char.ToUpper(r[0]) + r.Substring(1, r.Length - 1))) + " 필요)") : "";
}
}
public bool IsVisibleInTab(string activeTab)
{
if (string.IsNullOrEmpty(Tabs) || Tabs.Equals("all", StringComparison.OrdinalIgnoreCase))
{
return true;
}
IEnumerable<string> source = from t in Tabs.Split(',')
select t.Trim().ToLowerInvariant();
string tab = activeTab.ToLowerInvariant();
return source.Any((string t) => t == "all" || t == tab);
}
}

View File

@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
namespace AxCopilot.Services.Agent;
public class SkillManagerTool : IAgentTool
{
public string Name => "skill_manager";
public string Description => "마크다운 기반 스킬(워크플로우)을 관리합니다.\n- list: 사용 가능한 스킬 목록 조회\n- info: 특정 스킬의 상세 정보 확인\n- reload: 스킬 폴더를 다시 스캔하여 새 스킬 로드";
public ToolParameterSchema Parameters
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "list (목록), info (상세정보), reload (재로드)"
};
int num = 3;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "list";
span[1] = "info";
span[2] = "reload";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["skill_name"] = new ToolProperty
{
Type = "string",
Description = "스킬 이름 (info 액션에서 사용)"
};
toolParameterSchema.Properties = dictionary;
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "action";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
App app = Application.Current as App;
if (!(app?.SettingsService?.Settings.Llm.EnableSkillSystem ?? true))
{
return ToolResult.Ok("스킬 시스템이 비활성 상태입니다. 설정 → AX Agent → 공통에서 활성화하세요.");
}
JsonElement a;
string action = (args.TryGetProperty("action", out a) ? (a.GetString() ?? "") : "");
JsonElement s;
string skillName = (args.TryGetProperty("skill_name", out s) ? (s.GetString() ?? "") : "");
if (1 == 0)
{
}
ToolResult result = action switch
{
"list" => ListSkills(),
"info" => InfoSkill(skillName),
"reload" => ReloadSkills(app),
_ => ToolResult.Fail("지원하지 않는 action: " + action + ". list, info, reload 중 선택하세요."),
};
if (1 == 0)
{
}
return result;
}
private static ToolResult ListSkills()
{
IReadOnlyList<SkillDefinition> skills = SkillService.Skills;
if (skills.Count == 0)
{
return ToolResult.Ok("로드된 스킬이 없습니다. %APPDATA%\\AxCopilot\\skills\\에 *.skill.md 파일을 추가하세요.");
}
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(15, 1, stringBuilder2);
handler.AppendLiteral("사용 가능한 스킬 (");
handler.AppendFormatted(skills.Count);
handler.AppendLiteral("개):\n");
stringBuilder3.AppendLine(ref handler);
foreach (SkillDefinition item in skills)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(6, 2, stringBuilder2);
handler.AppendLiteral(" /");
handler.AppendFormatted(item.Name);
handler.AppendLiteral(" — ");
handler.AppendFormatted(item.Label);
stringBuilder4.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 1, stringBuilder2);
handler.AppendLiteral(" ");
handler.AppendFormatted(item.Description);
stringBuilder5.AppendLine(ref handler);
stringBuilder.AppendLine();
}
stringBuilder.AppendLine("슬래시 명령어(/{name})로 호출하거나, 대화에서 해당 워크플로우를 요청할 수 있습니다.");
return ToolResult.Ok(stringBuilder.ToString());
}
private static ToolResult InfoSkill(string name)
{
if (string.IsNullOrEmpty(name))
{
return ToolResult.Fail("skill_name이 필요합니다.");
}
SkillDefinition skillDefinition = SkillService.Find(name);
if (skillDefinition == null)
{
return ToolResult.Fail("'" + name + "' 스킬을 찾을 수 없습니다. skill_manager(action: list)로 목록을 확인하세요.");
}
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(11, 2, stringBuilder2);
handler.AppendLiteral("스킬 상세: ");
handler.AppendFormatted(skillDefinition.Label);
handler.AppendLiteral(" (/");
handler.AppendFormatted(skillDefinition.Name);
handler.AppendLiteral(")");
stringBuilder3.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 1, stringBuilder2);
handler.AppendLiteral("설명: ");
handler.AppendFormatted(skillDefinition.Description);
stringBuilder4.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 1, stringBuilder2);
handler.AppendLiteral("파일: ");
handler.AppendFormatted(skillDefinition.FilePath);
stringBuilder5.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(18, 1, stringBuilder2);
handler.AppendLiteral("\n--- 시스템 프롬프트 ---\n");
handler.AppendFormatted(skillDefinition.SystemPrompt);
stringBuilder6.AppendLine(ref handler);
return ToolResult.Ok(stringBuilder.ToString());
}
private static ToolResult ReloadSkills(App? app)
{
string customFolder = app?.SettingsService?.Settings.Llm.SkillsFolderPath ?? "";
SkillService.LoadSkills(customFolder);
return ToolResult.Ok($"스킬 재로드 완료. {SkillService.Skills.Count}개 로드됨.");
}
}

View File

@@ -0,0 +1,404 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
public static class SkillService
{
private static List<SkillDefinition> _skills = new List<SkillDefinition>();
private static string _lastFolder = "";
private static readonly Dictionary<string, string> ToolNameMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Bash"] = "process",
["bash"] = "process",
["Read"] = "file_read",
["Write"] = "file_write",
["Edit"] = "file_edit",
["Glob"] = "glob",
["Grep"] = "grep_tool",
["WebSearch"] = "http_tool",
["WebFetch"] = "http_tool",
["execute_command"] = "process",
["read_file"] = "file_read",
["write_file"] = "file_write",
["edit_file"] = "file_edit",
["search_files"] = "glob",
["search_content"] = "grep_tool",
["list_files"] = "folder_map",
["shell"] = "process",
["terminal"] = "process",
["cat"] = "file_read",
["find"] = "glob",
["rg"] = "grep_tool",
["git"] = "git_tool"
};
public static IReadOnlyList<SkillDefinition> Skills => _skills;
public static void LoadSkills(string? customFolder = null)
{
List<string> list = new List<string>();
string text = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "skills");
if (Directory.Exists(text))
{
list.Add(text);
}
string text2 = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills");
if (Directory.Exists(text2))
{
list.Add(text2);
}
if (!string.IsNullOrEmpty(customFolder) && Directory.Exists(customFolder))
{
list.Add(customFolder);
}
List<SkillDefinition> list2 = new List<SkillDefinition>();
HashSet<string> hashSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (string item in list)
{
string[] files = Directory.GetFiles(item, "*.skill.md");
foreach (string text3 in files)
{
try
{
SkillDefinition skillDefinition = ParseSkillFile(text3);
if (skillDefinition != null && hashSet.Add(skillDefinition.Name))
{
list2.Add(skillDefinition);
}
}
catch (Exception ex)
{
LogService.Warn("스킬 로드 실패 [" + text3 + "]: " + ex.Message);
}
}
try
{
string[] directories = Directory.GetDirectories(item);
foreach (string path in directories)
{
string text4 = Path.Combine(path, "SKILL.md");
if (!File.Exists(text4))
{
continue;
}
try
{
SkillDefinition skillDefinition2 = ParseSkillFile(text4);
if (skillDefinition2 != null && hashSet.Add(skillDefinition2.Name))
{
list2.Add(skillDefinition2);
}
}
catch (Exception ex2)
{
LogService.Warn("스킬 로드 실패 [" + text4 + "]: " + ex2.Message);
}
}
}
catch
{
}
}
foreach (SkillDefinition item2 in list2)
{
if (!string.IsNullOrEmpty(item2.Requires))
{
IEnumerable<string> source = from r in item2.Requires.Split(',')
select r.Trim();
item2.IsAvailable = source.All((string r) => RuntimeDetector.IsAvailable(r));
}
}
_skills = list2;
_lastFolder = customFolder ?? "";
int num = list2.Count((SkillDefinition s) => !s.IsAvailable);
LogService.Info($"스킬 {list2.Count}개 로드 완료" + ((num > 0) ? $" (런타임 미충족 {num}개)" : ""));
}
public static SkillDefinition? Find(string name)
{
return _skills.FirstOrDefault((SkillDefinition s) => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
}
public static List<SkillDefinition> MatchSlashCommand(string input)
{
if (!input.StartsWith('/'))
{
return new List<SkillDefinition>();
}
return _skills.Where((SkillDefinition s) => ("/" + s.Name).StartsWith(input, StringComparison.OrdinalIgnoreCase)).ToList();
}
public static void EnsureSkillFolder()
{
string text = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills");
if (!Directory.Exists(text))
{
Directory.CreateDirectory(text);
}
CreateExampleSkill(text, "daily-standup.skill.md", "daily-standup", "데일리 스탠드업", "작업 폴더의 최근 변경사항을 요약하여 데일리 스탠드업 보고서를 생성합니다.", "작업 폴더의 Git 상태와 최근 커밋을 분석하여 데일리 스탠드업 보고서를 작성하세요.\n\n다음 도구를 사용하세요:\n1. git_tool (action: log, args: \"--oneline -10\") — 최근 커밋 확인\n2. git_tool (action: status) — 현재 변경사항 확인\n3. git_tool (action: diff, args: \"--stat\") — 변경 파일 통계\n\n보고서 형식:\n## \ud83d\udccb 데일리 스탠드업 보고서\n\n### ✅ 완료한 작업\n- 최근 커밋 기반으로 정리\n\n### \ud83d\udd04 진행 중인 작업\n- 현재 수정 중인 파일 기반\n\n### ⚠\ufe0f 블로커/이슈\n- TODO/FIXME가 있으면 표시\n\n한국어로 작성하세요.");
CreateExampleSkill(text, "bug-hunt.skill.md", "bug-hunt", "버그 탐색", "작업 폴더에서 잠재적 버그 패턴을 검색합니다.", "작업 폴더의 코드에서 잠재적 버그 패턴을 찾아 보고하세요.\n\n다음 도구를 사용하세요:\n1. grep_tool — 위험 패턴 검색:\n - 빈 catch 블록: catch\\s*\\{\\s*\\}\n - TODO/FIXME: (TODO|FIXME|HACK|XXX)\n - .Result/.Wait(): \\.(Result|Wait\\(\\))\n - 하드코딩된 자격증명: (password|secret|apikey)\\s*=\\s*\"\n2. code_review (action: diff_review, focus: bugs) — 최근 변경사항 버그 검사\n\n결과를 심각도별로 분류하여 보고하세요:\n- \ud83d\udd34 CRITICAL: 즉시 수정 필요\n- \ud83d\udfe1 WARNING: 검토 필요\n- \ud83d\udd35 INFO: 개선 권장\n\n한국어로 작성하세요.");
CreateExampleSkill(text, "code-explain.skill.md", "code-explain", "코드 설명", "지정한 파일의 코드를 상세히 설명합니다.", "사용자가 지정한 파일 또는 작업 폴더의 주요 파일을 읽고 상세히 설명하세요.\n\n다음 도구를 사용하세요:\n1. file_read — 파일 내용 읽기\n2. folder_map — 프로젝트 구조 파악 (필요시)\n\n설명 포함 사항:\n- 파일의 역할과 책임\n- 주요 클래스/함수의 목적\n- 데이터 흐름\n- 외부 의존성\n- 개선 포인트 (있다면)\n\n한국어로 쉽게 설명하세요. 코드 블록을 활용하여 핵심 부분을 인용하세요.");
}
public static string? ExportSkill(SkillDefinition skill, string outputDir)
{
try
{
if (!File.Exists(skill.FilePath))
{
LogService.Warn("스킬 내보내기 실패: 파일 없음 — " + skill.FilePath);
return null;
}
string path = skill.Name + ".skill.zip";
string text = Path.Combine(outputDir, path);
if (File.Exists(text))
{
File.Delete(text);
}
using ZipArchive destination = ZipFile.Open(text, ZipArchiveMode.Create);
if (skill.IsStandardFormat)
{
string directoryName = Path.GetDirectoryName(skill.FilePath);
if (directoryName != null && Directory.Exists(directoryName))
{
string fileName = Path.GetFileName(directoryName);
foreach (string item in Directory.EnumerateFiles(directoryName, "*", SearchOption.AllDirectories))
{
bool flag;
switch (Path.GetExtension(item).ToLowerInvariant())
{
case ".exe":
case ".dll":
case ".bat":
case ".cmd":
case ".ps1":
case ".sh":
flag = true;
break;
default:
flag = false;
break;
}
if (!flag)
{
string entryName = fileName + "/" + Path.GetRelativePath(directoryName, item).Replace('\\', '/');
destination.CreateEntryFromFile(item, entryName, CompressionLevel.Optimal);
}
}
}
}
else
{
string entryName2 = skill.Name + "/" + Path.GetFileName(skill.FilePath);
destination.CreateEntryFromFile(skill.FilePath, entryName2, CompressionLevel.Optimal);
}
LogService.Info("스킬 내보내기 완료: " + text);
return text;
}
catch (Exception ex)
{
LogService.Warn("스킬 내보내기 실패: " + ex.Message);
return null;
}
}
public static int ImportSkills(string zipPath)
{
try
{
if (!File.Exists(zipPath))
{
LogService.Warn("스킬 가져오기 실패: 파일 없음 — " + zipPath);
return 0;
}
string text = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills");
if (!Directory.Exists(text))
{
Directory.CreateDirectory(text);
}
using ZipArchive zipArchive = ZipFile.OpenRead(zipPath);
HashSet<string> hashSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".exe", ".dll", ".bat", ".cmd", ".ps1", ".sh", ".com", ".scr", ".msi" };
foreach (ZipArchiveEntry entry in zipArchive.Entries)
{
if (hashSet.Contains(Path.GetExtension(entry.Name)))
{
LogService.Warn("스킬 가져오기 차단: 실행 가능 파일 포함 — " + entry.FullName);
return 0;
}
}
List<ZipArchiveEntry> list = zipArchive.Entries.Where((ZipArchiveEntry e) => e.Name.EndsWith(".skill.md", StringComparison.OrdinalIgnoreCase) || e.Name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase)).ToList();
if (list.Count == 0)
{
LogService.Warn("스킬 가져오기 실패: zip에 .skill.md 또는 SKILL.md 파일 없음");
return 0;
}
int num = 0;
foreach (ZipArchiveEntry entry2 in zipArchive.Entries)
{
if (string.IsNullOrEmpty(entry2.Name))
{
continue;
}
string text2 = entry2.FullName.Replace('/', Path.DirectorySeparatorChar);
if (!text2.Contains(".."))
{
string text3 = Path.Combine(text, text2);
string directoryName = Path.GetDirectoryName(text3);
if (directoryName != null && !Directory.Exists(directoryName))
{
Directory.CreateDirectory(directoryName);
}
entry2.ExtractToFile(text3, overwrite: true);
if (entry2.Name.EndsWith(".skill.md", StringComparison.OrdinalIgnoreCase) || entry2.Name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase))
{
num++;
}
}
}
if (num > 0)
{
LogService.Info($"스킬 가져오기 완료: {num}개 스킬 ({zipPath})");
LoadSkills();
}
return num;
}
catch (Exception ex)
{
LogService.Warn("스킬 가져오기 실패: " + ex.Message);
return 0;
}
}
public static string MapToolNames(string skillBody)
{
if (string.IsNullOrEmpty(skillBody))
{
return skillBody;
}
foreach (KeyValuePair<string, string> item in ToolNameMap)
{
skillBody = skillBody.Replace("`" + item.Key + "`", "`" + item.Value + "`");
skillBody = skillBody.Replace("(" + item.Key + ")", "(" + item.Value + ")");
}
return skillBody;
}
private static void CreateExampleSkill(string folder, string fileName, string name, string label, string description, string body)
{
string path = Path.Combine(folder, fileName);
if (!File.Exists(path))
{
string text = $"---\nname: {name}\nlabel: {label}\ndescription: {description}\nicon: \\uE768\n---\n\n{body.Trim()}";
string[] value = (from l in text.Split('\n')
select l.TrimStart()).ToArray();
File.WriteAllText(path, string.Join('\n', value), Encoding.UTF8);
}
}
private static SkillDefinition? ParseSkillFile(string filePath)
{
string text = File.ReadAllText(filePath, Encoding.UTF8);
if (!text.TrimStart().StartsWith("---"))
{
return null;
}
int num = text.IndexOf("---", StringComparison.Ordinal);
int num2 = text.IndexOf("---", num + 3, StringComparison.Ordinal);
if (num2 < 0)
{
return null;
}
int num3 = num + 3;
string text2 = text.Substring(num3, num2 - num3).Trim();
string text3 = text;
num3 = num2 + 3;
string skillBody = text3.Substring(num3, text3.Length - num3).Trim();
Dictionary<string, string> dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string text4 = null;
string[] array = text2.Split('\n');
foreach (string text5 in array)
{
if (text4 != null && (text5.StartsWith(" ") || text5.StartsWith("\t")))
{
string text6 = text5.TrimStart();
int num4 = text6.IndexOf(':');
if (num4 > 0)
{
string text7 = text6.Substring(0, num4).Trim();
text3 = text6;
num3 = num4 + 1;
string value = text3.Substring(num3, text3.Length - num3).Trim().Trim('"', '\'');
dictionary[text4 + "." + text7] = value;
}
continue;
}
text4 = null;
int num5 = text5.IndexOf(':');
if (num5 > 0)
{
string text8 = text5.Substring(0, num5).Trim();
text3 = text5;
num3 = num5 + 1;
string value2 = text3.Substring(num3, text3.Length - num3).Trim().Trim('"', '\'');
if (string.IsNullOrEmpty(value2))
{
text4 = text8;
}
else
{
dictionary[text8] = value2;
}
}
}
string fileName = Path.GetFileName(Path.GetDirectoryName(filePath) ?? "");
string text9 = Path.GetFileNameWithoutExtension(filePath).Replace(".skill", "");
string defaultValue = (filePath.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase) ? fileName : text9);
string valueOrDefault = dictionary.GetValueOrDefault("name", defaultValue);
if (string.IsNullOrEmpty(valueOrDefault))
{
return null;
}
string text10 = dictionary.GetValueOrDefault("label", "") ?? "";
string value3 = dictionary.GetValueOrDefault("icon", "") ?? "";
if (string.IsNullOrEmpty(text10) && dictionary.TryGetValue("metadata.label", out var value4))
{
text10 = value4 ?? "";
}
if (string.IsNullOrEmpty(value3) && dictionary.TryGetValue("metadata.icon", out var value5))
{
value3 = value5 ?? "";
}
return new SkillDefinition
{
Id = valueOrDefault,
Name = valueOrDefault,
Label = (string.IsNullOrEmpty(text10) ? valueOrDefault : text10),
Description = (dictionary.GetValueOrDefault("description", "") ?? ""),
Icon = (string.IsNullOrEmpty(value3) ? "\ue768" : ConvertUnicodeEscape(value3)),
SystemPrompt = MapToolNames(skillBody),
FilePath = filePath,
License = (dictionary.GetValueOrDefault("license", "") ?? ""),
Compatibility = (dictionary.GetValueOrDefault("compatibility", "") ?? ""),
AllowedTools = (dictionary.GetValueOrDefault("allowed-tools", "") ?? ""),
Requires = (dictionary.GetValueOrDefault("requires", "") ?? ""),
Tabs = (dictionary.GetValueOrDefault("tabs", "all") ?? "all")
};
}
private static string ConvertUnicodeEscape(string value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
return Regex.Replace(value, "\\\\u([0-9a-fA-F]{4})", (Match m) => ((char)Convert.ToInt32(m.Groups[1].Value, 16)).ToString());
}
}

View File

@@ -0,0 +1,284 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
namespace AxCopilot.Services.Agent;
public class SnippetRunnerTool : IAgentTool
{
private static readonly string[] DangerousPatterns = new string[18]
{
"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 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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Programming language: 'csharp', 'python', or 'javascript'"
};
int num = 3;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "csharp";
span[1] = "python";
span[2] = "javascript";
obj.Enum = list;
dictionary["language"] = obj;
dictionary["code"] = new ToolProperty
{
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."
};
dictionary["timeout"] = new ToolProperty
{
Type = "integer",
Description = "Timeout in seconds. Default: 30, max: 60."
};
toolParameterSchema.Properties = dictionary;
num = 2;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
Span<string> span2 = CollectionsMarshal.AsSpan(list2);
span2[0] = "language";
span2[1] = "code";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string language = args.GetProperty("language").GetString() ?? "";
JsonElement c;
string code = (args.TryGetProperty("code", out c) ? (c.GetString() ?? "") : "");
JsonElement to;
int timeout = (args.TryGetProperty("timeout", out to) ? Math.Min(to.GetInt32(), 60) : 30);
if (string.IsNullOrWhiteSpace(code))
{
return ToolResult.Fail("code가 비어 있습니다.");
}
string[] dangerousPatterns = DangerousPatterns;
foreach (string 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.Substring(0, 100) + "...") : code))))
{
return ToolResult.Fail("코드 실행 권한 거부");
}
if (!((Application.Current as App)?.SettingsService?.Settings?.Llm?.Code?.EnableSnippetRunner ?? true))
{
return ToolResult.Fail("snippet_runner가 설정에서 비활성화되어 있습니다. 설정 → AX Agent → 기능에서 활성화하세요.");
}
if (1 == 0)
{
}
ToolResult result = 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 중 선택하세요."),
};
if (1 == 0)
{
}
return result;
}
private async Task<ToolResult> RunCSharpAsync(string code, int timeout, AgentContext context, CancellationToken ct)
{
string tempDir = Path.Combine(Path.GetTempPath(), $"axcopilot_snippet_{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
if (await IsToolAvailableAsync("dotnet-script"))
{
string scriptFile = Path.Combine(tempDir, "snippet.csx");
await File.WriteAllTextAsync(scriptFile, code, ct);
return await RunProcessAsync("dotnet-script", scriptFile, timeout, tempDir, ct);
}
string csprojContent = "<Project Sdk=\"Microsoft.NET.Sdk\">\n <PropertyGroup>\n <OutputType>Exe</OutputType>\n <TargetFramework>net8.0</TargetFramework>\n <ImplicitUsings>enable</ImplicitUsings>\n </PropertyGroup>\n</Project>";
await File.WriteAllTextAsync(Path.Combine(tempDir, "Snippet.csproj"), csprojContent, ct);
await File.WriteAllTextAsync(contents: (code.Contains("class ") && code.Contains("static void Main")) ? code : code, path: Path.Combine(tempDir, "Program.cs"), cancellationToken: ct);
return await RunProcessAsync("dotnet", "run --project \"" + tempDir + "\"", timeout, tempDir, ct);
}
finally
{
try
{
Directory.Delete(tempDir, recursive: 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 + "을 설치하세요.");
}
string 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)
{
ProcessStartInfo 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 Process process = new Process
{
StartInfo = psi
};
StringBuilder stdout = new StringBuilder();
StringBuilder stderr = new StringBuilder();
process.OutputDataReceived += delegate(object _, DataReceivedEventArgs e)
{
if (e.Data != null)
{
stdout.AppendLine(e.Data);
}
};
process.ErrorDataReceived += delegate(object _, DataReceivedEventArgs e)
{
if (e.Data != null)
{
stderr.AppendLine(e.Data);
}
};
try
{
process.Start();
}
catch (Exception ex)
{
Exception ex2 = ex;
return ToolResult.Fail("실행 실패: " + ex2.Message);
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using CancellationTokenSource 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}초 초과)");
}
string output = stdout.ToString().TrimEnd();
string error = stderr.ToString().TrimEnd();
if (output.Length > 8000)
{
output = output.Substring(0, 8000) + "\n... (출력 잘림)";
}
StringBuilder result = new StringBuilder();
StringBuilder stringBuilder = result;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(13, 1, stringBuilder);
handler.AppendLiteral("[Exit code: ");
handler.AppendFormatted(process.ExitCode);
handler.AppendLiteral("]");
stringBuilder2.AppendLine(ref handler);
if (!string.IsNullOrEmpty(output))
{
result.AppendLine(output);
}
if (!string.IsNullOrEmpty(error))
{
stringBuilder = result;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder);
handler.AppendLiteral("[stderr]\n");
handler.AppendFormatted(error);
stringBuilder3.AppendLine(ref handler);
}
return ToolResult.Ok(result.ToString());
}
private static async Task<bool> IsToolAvailableAsync(string tool)
{
try
{
ProcessStartInfo psi = new ProcessStartInfo
{
FileName = ((Environment.OSVersion.Platform == PlatformID.Win32NT) ? "where" : "which"),
Arguments = tool,
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
};
using Process process = Process.Start(psi);
if (process == null)
{
return false;
}
await process.WaitForExitAsync();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,272 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Action to perform"
};
int num = 4;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "query";
span[1] = "execute";
span[2] = "schema";
span[3] = "tables";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["db_path"] = new ToolProperty
{
Type = "string",
Description = "Path to SQLite database file (.db, .sqlite, .sqlite3)"
};
dictionary["sql"] = new ToolProperty
{
Type = "string",
Description = "SQL query to execute (for query/execute actions)"
};
dictionary["max_rows"] = new ToolProperty
{
Type = "string",
Description = "Maximum rows to return (default: 100, max: 1000)"
};
toolParameterSchema.Properties = dictionary;
num = 2;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
Span<string> span2 = CollectionsMarshal.AsSpan(list2);
span2[0] = "action";
span2[1] = "db_path";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string text = args.GetProperty("action").GetString() ?? "";
string text2 = args.GetProperty("db_path").GetString() ?? "";
if (!Path.IsPathRooted(text2))
{
text2 = Path.Combine(context.WorkFolder, text2);
}
if (!File.Exists(text2))
{
return Task.FromResult(ToolResult.Fail("Database file not found: " + text2));
}
try
{
string connectionString = "Data Source=" + text2 + ";Mode=ReadOnly";
if (text == "execute")
{
connectionString = "Data Source=" + text2;
}
using SqliteConnection sqliteConnection = new SqliteConnection(connectionString);
sqliteConnection.Open();
if (1 == 0)
{
}
ToolResult result = text switch
{
"query" => QueryAction(sqliteConnection, args),
"execute" => ExecuteAction(sqliteConnection, args),
"schema" => SchemaAction(sqliteConnection),
"tables" => TablesAction(sqliteConnection),
_ => ToolResult.Fail("Unknown action: " + text),
};
if (1 == 0)
{
}
return Task.FromResult(result);
}
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 value))
{
return ToolResult.Fail("'sql' parameter is required for query action");
}
string text = value.GetString() ?? "";
if (!text.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase) && !text.TrimStart().StartsWith("WITH", StringComparison.OrdinalIgnoreCase) && !text.TrimStart().StartsWith("PRAGMA", StringComparison.OrdinalIgnoreCase))
{
return ToolResult.Fail("Query action only allows SELECT/WITH/PRAGMA statements. Use 'execute' for modifications.");
}
JsonElement value2;
int result;
int num = ((args.TryGetProperty("max_rows", out value2) && int.TryParse(value2.GetString(), out result)) ? Math.Min(result, 1000) : 100);
using SqliteCommand sqliteCommand = conn.CreateCommand();
sqliteCommand.CommandText = text;
using SqliteDataReader sqliteDataReader = sqliteCommand.ExecuteReader();
StringBuilder stringBuilder = new StringBuilder();
int fieldCount = sqliteDataReader.FieldCount;
string[] array = new string[fieldCount];
for (int i = 0; i < fieldCount; i++)
{
array[i] = sqliteDataReader.GetName(i);
}
stringBuilder.AppendLine(string.Join(" | ", array));
stringBuilder.AppendLine(new string('-', array.Sum((string c) => c.Length + 3)));
int num2 = 0;
while (sqliteDataReader.Read() && num2 < num)
{
string[] array2 = new string[fieldCount];
for (int num3 = 0; num3 < fieldCount; num3++)
{
array2[num3] = (sqliteDataReader.IsDBNull(num3) ? "NULL" : (sqliteDataReader.GetValue(num3)?.ToString() ?? ""));
}
stringBuilder.AppendLine(string.Join(" | ", array2));
num2++;
}
if (num2 == 0)
{
return ToolResult.Ok("Query returned 0 rows.");
}
string text2 = stringBuilder.ToString();
if (text2.Length > 8000)
{
text2 = text2.Substring(0, 8000) + "\n... (truncated)";
}
return ToolResult.Ok($"Rows: {num2}" + ((num2 >= num) ? $" (limited to {num})" : "") + "\n\n" + text2);
}
private static ToolResult ExecuteAction(SqliteConnection conn, JsonElement args)
{
if (!args.TryGetProperty("sql", out var value))
{
return ToolResult.Fail("'sql' parameter is required for execute action");
}
string text = value.GetString() ?? "";
string text2 = text.TrimStart().ToUpperInvariant();
if (text2.StartsWith("DROP DATABASE") || text2.StartsWith("ATTACH") || text2.StartsWith("DETACH"))
{
return ToolResult.Fail("Security: DROP DATABASE, ATTACH, DETACH are not allowed.");
}
using SqliteCommand sqliteCommand = conn.CreateCommand();
sqliteCommand.CommandText = text;
int value2 = sqliteCommand.ExecuteNonQuery();
return ToolResult.Ok($"✓ {value2} row(s) affected");
}
private static ToolResult SchemaAction(SqliteConnection conn)
{
StringBuilder stringBuilder = new StringBuilder();
using SqliteCommand sqliteCommand = conn.CreateCommand();
sqliteCommand.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name";
using SqliteDataReader sqliteDataReader = sqliteCommand.ExecuteReader();
List<string> list = new List<string>();
while (sqliteDataReader.Read())
{
list.Add(sqliteDataReader.GetString(0));
}
sqliteDataReader.Close();
foreach (string item in list)
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2);
handler.AppendLiteral("## ");
handler.AppendFormatted(item);
stringBuilder3.AppendLine(ref handler);
using SqliteCommand sqliteCommand2 = conn.CreateCommand();
sqliteCommand2.CommandText = "PRAGMA table_info(\"" + item + "\")";
using SqliteDataReader sqliteDataReader2 = sqliteCommand2.ExecuteReader();
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(5, 6, stringBuilder2);
handler.AppendFormatted<string>("#", -4);
handler.AppendLiteral(" ");
handler.AppendFormatted<string>("Name", -25);
handler.AppendLiteral(" ");
handler.AppendFormatted<string>("Type", -15);
handler.AppendLiteral(" ");
handler.AppendFormatted<string>("NotNull", -8);
handler.AppendLiteral(" ");
handler.AppendFormatted<string>("Default", -15);
handler.AppendLiteral(" ");
handler.AppendFormatted("PK");
stringBuilder4.AppendLine(ref handler);
while (sqliteDataReader2.Read())
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(5, 6, stringBuilder2);
handler.AppendFormatted(sqliteDataReader2.GetInt32(0), -4);
handler.AppendLiteral(" ");
handler.AppendFormatted<string>(sqliteDataReader2.GetString(1), -25);
handler.AppendLiteral(" ");
handler.AppendFormatted<string>(sqliteDataReader2.GetString(2), -15);
handler.AppendLiteral(" ");
handler.AppendFormatted<string>((sqliteDataReader2.GetInt32(3) == 1) ? "YES" : "", -8);
handler.AppendLiteral(" ");
handler.AppendFormatted<string>(sqliteDataReader2.IsDBNull(4) ? "" : sqliteDataReader2.GetString(4), -15);
handler.AppendLiteral(" ");
handler.AppendFormatted((sqliteDataReader2.GetInt32(5) > 0) ? "PK" : "");
stringBuilder5.AppendLine(ref handler);
}
sqliteDataReader2.Close();
stringBuilder.AppendLine();
}
return ToolResult.Ok(stringBuilder.ToString());
}
private static ToolResult TablesAction(SqliteConnection conn)
{
using SqliteCommand sqliteCommand = conn.CreateCommand();
sqliteCommand.CommandText = "\n SELECT m.name, m.type,\n (SELECT count(*) FROM pragma_table_info(m.name)) as col_count\n FROM sqlite_master m\n WHERE m.type IN ('table','view')\n ORDER BY m.type, m.name";
using SqliteDataReader sqliteDataReader = sqliteCommand.ExecuteReader();
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(2, 3, stringBuilder2);
handler.AppendFormatted<string>("Name", -30);
handler.AppendLiteral(" ");
handler.AppendFormatted<string>("Type", -8);
handler.AppendLiteral(" ");
handler.AppendFormatted("Columns");
stringBuilder3.AppendLine(ref handler);
stringBuilder.AppendLine(new string('-', 50));
int num = 0;
while (sqliteDataReader.Read())
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(2, 3, stringBuilder2);
handler.AppendFormatted<string>(sqliteDataReader.GetString(0), -30);
handler.AppendLiteral(" ");
handler.AppendFormatted<string>(sqliteDataReader.GetString(1), -8);
handler.AppendLiteral(" ");
handler.AppendFormatted(sqliteDataReader.GetInt32(2));
stringBuilder4.AppendLine(ref handler);
num++;
}
return ToolResult.Ok($"Found {num} tables/views:\n\n{stringBuilder}");
}
}

View File

@@ -0,0 +1,8 @@
namespace AxCopilot.Services.Agent;
public enum SubAgentRunStatus
{
Started,
Completed,
Failed
}

View File

@@ -0,0 +1,18 @@
using System;
namespace AxCopilot.Services.Agent;
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,21 @@
using System;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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; }
}

View File

@@ -0,0 +1,403 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public class SubAgentTool : IAgentTool
{
private static readonly Dictionary<string, SubAgentTask> _activeTasks = new Dictionary<string, SubAgentTask>();
private static readonly object _lock = new object();
public string Name => "spawn_agent";
public string Description => "Create a read-only sub-agent for bounded parallel research or codebase analysis.\nUse this when a side task can run independently while the main agent continues.\nThe sub-agent can inspect files, search code, review diffs, and summarize findings.\nCollect results later with wait_agents.";
public ToolParameterSchema Parameters => new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["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 List<string> { "task", "id" }
};
public static IReadOnlyDictionary<string, SubAgentTask> ActiveTasks
{
get
{
lock (_lock)
{
return new Dictionary<string, SubAgentTask>(_activeTasks);
}
}
}
public static event Action<SubAgentStatusEvent>? StatusChanged;
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
JsonElement value;
string task = (args.TryGetProperty("task", out value) ? (value.GetString() ?? "") : "");
JsonElement value2;
string id = (args.TryGetProperty("id", out value2) ? (value2.GetString() ?? "") : "");
if (string.IsNullOrWhiteSpace(task) || string.IsNullOrWhiteSpace(id))
{
return Task.FromResult(ToolResult.Fail("task and id are required."));
}
CleanupStale();
int num = ((!(Application.Current is App app)) ? ((int?)null) : app.SettingsService?.Settings.Llm.MaxSubAgents) ?? 3;
lock (_lock)
{
if (_activeTasks.ContainsKey(id))
{
return Task.FromResult(ToolResult.Fail("Sub-agent id already exists: " + id));
}
int num2 = _activeTasks.Values.Count((SubAgentTask x) => !x.CompletedAt.HasValue);
if (num2 >= num)
{
return Task.FromResult(ToolResult.Fail($"Maximum concurrent sub-agents reached ({num})."));
}
}
SubAgentTask subTask = new SubAgentTask
{
Id = id,
Task = task,
StartedAt = DateTime.Now
};
subTask.RunTask = Task.Run(async delegate
{
try
{
string result = await RunSubAgentAsync(id, task, context).ConfigureAwait(continueOnCapturedContext: 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)
{
Exception ex2 = ex;
subTask.Result = "Error: " + ex2.Message;
subTask.Success = false;
NotifyStatus(new SubAgentStatusEvent
{
Id = id,
Task = task,
Status = SubAgentRunStatus.Failed,
Summary = "Sub-agent '" + id + "' failed: " + ex2.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)
{
SettingsService settings = CreateSubAgentSettings(parentContext);
using LlmService llm = new LlmService(settings);
using ToolRegistry tools = await CreateSubAgentRegistryAsync(settings).ConfigureAwait(continueOnCapturedContext: false);
AgentLoopService loop = new AgentLoopService(llm, tools, settings)
{
ActiveTab = parentContext.ActiveTab
};
List<ChatMessage> messages = new List<ChatMessage>
{
new ChatMessage
{
Role = "system",
Content = BuildSubAgentSystemPrompt(parentContext)
},
new ChatMessage
{
Role = "user",
Content = task
}
};
string finalText = await loop.RunAsync(messages, CancellationToken.None).ConfigureAwait(continueOnCapturedContext: false);
string eventSummary = SummarizeEvents(loop.Events);
StringBuilder sb = new StringBuilder();
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(12, 1, stringBuilder);
handler.AppendLiteral("[Sub-agent ");
handler.AppendFormatted(id);
handler.AppendLiteral("]");
stringBuilder2.AppendLine(ref handler);
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(6, 1, stringBuilder);
handler.AppendLiteral("Task: ");
handler.AppendFormatted(task);
stringBuilder3.AppendLine(ref handler);
if (!string.IsNullOrWhiteSpace(parentContext.WorkFolder))
{
stringBuilder = sb;
StringBuilder stringBuilder4 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(13, 1, stringBuilder);
handler.AppendLiteral("Work folder: ");
handler.AppendFormatted(parentContext.WorkFolder);
stringBuilder4.AppendLine(ref handler);
}
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)
{
SettingsService settingsService = new SettingsService();
settingsService.Load();
LlmSettings llm = settingsService.Settings.Llm;
llm.WorkFolder = parentContext.WorkFolder;
llm.FilePermission = "Deny";
llm.PlanMode = "off";
llm.AgentHooks = new List<AgentHookEntry>();
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 settingsService;
}
private static async Task<ToolRegistry> CreateSubAgentRegistryAsync(SettingsService settings)
{
ToolRegistry 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(continueOnCapturedContext: false);
return registry;
}
private static string BuildSubAgentSystemPrompt(AgentContext parentContext)
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("You are a focused sub-agent for AX Copilot.");
stringBuilder.AppendLine("You are running a bounded, read-only investigation.");
stringBuilder.AppendLine("Use tools to inspect the project and gather evidence, then return a concise result.");
stringBuilder.AppendLine("Do not ask the user questions.");
stringBuilder.AppendLine("Do not attempt file edits, command execution, notifications, or external side effects.");
stringBuilder.AppendLine("Prefer direct evidence from files and tool results over speculation.");
stringBuilder.AppendLine("If something is uncertain, say so briefly.");
if (!string.IsNullOrWhiteSpace(parentContext.WorkFolder))
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(21, 1, stringBuilder2);
handler.AppendLiteral("Current work folder: ");
handler.AppendFormatted(parentContext.WorkFolder);
stringBuilder3.AppendLine(ref handler);
}
if (!string.IsNullOrWhiteSpace(parentContext.ActiveTab))
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(13, 1, stringBuilder2);
handler.AppendLiteral("Current tab: ");
handler.AppendFormatted(parentContext.ActiveTab);
stringBuilder4.AppendLine(ref handler);
}
stringBuilder.AppendLine("Final answer format:");
stringBuilder.AppendLine("1. Short conclusion");
stringBuilder.AppendLine("2. Key evidence");
stringBuilder.AppendLine("3. Risks or unknowns");
return stringBuilder.ToString().TrimEnd();
}
private static string SummarizeEvents(IEnumerable<AgentEvent> events)
{
List<string> list = events.Where(delegate(AgentEvent e)
{
AgentEventType type = e.Type;
return (type == AgentEventType.StepStart || (uint)(type - 4) <= 1u) ? true : false;
}).TakeLast(12).Select(delegate(AgentEvent e)
{
AgentEventType type = e.Type;
if (1 == 0)
{
}
string text = type switch
{
AgentEventType.ToolCall => "tool:" + e.ToolName,
AgentEventType.ToolResult => "result:" + e.ToolName,
AgentEventType.StepStart => "step",
_ => e.Type.ToString().ToLowerInvariant(),
};
if (1 == 0)
{
}
string text2 = text;
string text3 = (string.IsNullOrWhiteSpace(e.Summary) ? "" : (" - " + e.Summary.Trim()));
return "- " + text2 + text3;
})
.ToList();
return (list.Count == 0) ? "" : string.Join(Environment.NewLine, list);
}
public static async Task<string> WaitAsync(IEnumerable<string>? ids = null, bool completedOnly = false, CancellationToken ct = default(CancellationToken))
{
List<SubAgentTask> tasks;
lock (_lock)
{
tasks = _activeTasks.Values.ToList();
}
List<string> requestedIds = (from x in ids?.Where((string x) => !string.IsNullOrWhiteSpace(x))
select x.Trim()).Distinct<string>(StringComparer.OrdinalIgnoreCase).ToList();
if (requestedIds != null && requestedIds.Count > 0)
{
tasks = tasks.Where((SubAgentTask t) => requestedIds.Contains<string>(t.Id, StringComparer.OrdinalIgnoreCase)).ToList();
}
if (tasks.Count == 0)
{
if (requestedIds != null && requestedIds.Count > 0)
{
return "No matching sub-agents found for: " + string.Join(", ", requestedIds);
}
return "No active sub-agents.";
}
if (!completedOnly)
{
await Task.WhenAll(from t in tasks
where t.RunTask != null
select t.RunTask).WaitAsync(ct);
}
else
{
tasks = tasks.Where((SubAgentTask t) => t.CompletedAt.HasValue).ToList();
}
if (tasks.Count == 0)
{
return "No requested sub-agents have completed yet.";
}
StringBuilder sb = new StringBuilder();
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(31, 1, stringBuilder);
handler.AppendLiteral("Collected ");
handler.AppendFormatted(tasks.Count);
handler.AppendLiteral(" sub-agent result(s):");
stringBuilder2.AppendLine(ref handler);
foreach (SubAgentTask task in tasks.OrderBy((SubAgentTask t) => t.StartedAt))
{
string status = (task.Success ? "OK" : "FAIL");
string duration = (task.CompletedAt.HasValue ? $"{(task.CompletedAt.Value - task.StartedAt).TotalSeconds:F1}s" : "running");
sb.AppendLine();
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(14, 3, stringBuilder);
handler.AppendLiteral("--- [");
handler.AppendFormatted(status);
handler.AppendLiteral("] ");
handler.AppendFormatted(task.Id);
handler.AppendLiteral(" (");
handler.AppendFormatted(duration);
handler.AppendLiteral(") ---");
stringBuilder3.AppendLine(ref handler);
sb.AppendLine(task.Result ?? "(no result)");
}
lock (_lock)
{
foreach (SubAgentTask task2 in tasks)
{
_activeTasks.Remove(task2.Id);
}
}
return sb.ToString().TrimEnd();
}
public static void CleanupStale()
{
lock (_lock)
{
List<string> list = (from kv in _activeTasks
where kv.Value.CompletedAt.HasValue && (DateTime.Now - kv.Value.CompletedAt.Value).TotalMinutes > 10.0
select kv.Key).ToList();
foreach (string item in list)
{
_activeTasks.Remove(item);
}
}
}
private static void NotifyStatus(SubAgentStatusEvent evt)
{
try
{
SubAgentTool.StatusChanged?.Invoke(evt);
}
catch
{
}
}
}

View File

@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["actions"] = new ToolProperty
{
Type = "array",
Description = "List of action objects. Each object: {\"label\": \"표시 텍스트\", \"command\": \"/slash 또는 자연어\", \"icon\": \"\\uE8A5\" (optional), \"priority\": \"high|medium|low\"}",
Items = new ToolProperty
{
Type = "object",
Description = "Action object with label, command, icon, priority"
}
},
["context"] = new ToolProperty
{
Type = "string",
Description = "Current task context summary (optional)"
}
}
};
int num = 1;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
CollectionsMarshal.AsSpan(list)[0] = "actions";
obj.Required = list;
return obj;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
try
{
if (!args.TryGetProperty("actions", out var value) || value.ValueKind != JsonValueKind.Array)
{
return Task.FromResult(ToolResult.Fail("actions 배열이 필요합니다."));
}
List<Dictionary<string, string>> list = new List<Dictionary<string, string>>();
foreach (JsonElement item in value.EnumerateArray())
{
JsonElement value3;
string value2 = (item.TryGetProperty("label", out value3) ? (value3.GetString() ?? "") : "");
JsonElement value5;
string value4 = (item.TryGetProperty("command", out value5) ? (value5.GetString() ?? "") : "");
JsonElement value7;
string value6 = (item.TryGetProperty("icon", out value7) ? (value7.GetString() ?? "") : "");
JsonElement value9;
string value8 = (item.TryGetProperty("priority", out value9) ? (value9.GetString() ?? "medium") : "medium");
if (string.IsNullOrWhiteSpace(value2))
{
return Task.FromResult(ToolResult.Fail("각 action에는 label이 필요합니다."));
}
if (string.IsNullOrWhiteSpace(value4))
{
return Task.FromResult(ToolResult.Fail("각 action에는 command가 필요합니다."));
}
string[] source = new string[3] { "high", "medium", "low" };
if (!source.Contains(value8))
{
value8 = "medium";
}
Dictionary<string, string> dictionary = new Dictionary<string, string>
{
["label"] = value2,
["command"] = value4,
["priority"] = value8
};
if (!string.IsNullOrEmpty(value6))
{
dictionary["icon"] = value6;
}
list.Add(dictionary);
}
if (list.Count < 1 || list.Count > 5)
{
return Task.FromResult(ToolResult.Fail("actions는 1~5개 사이여야 합니다."));
}
JsonElement value11;
string value10 = (args.TryGetProperty("context", out value11) ? (value11.GetString() ?? "") : "");
Dictionary<string, object> dictionary2 = new Dictionary<string, object>
{
["type"] = "suggest_actions",
["actions"] = list
};
if (!string.IsNullOrEmpty(value10))
{
dictionary2["context"] = value10;
}
string output = JsonSerializer.Serialize(dictionary2, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
return Task.FromResult(ToolResult.Ok(output));
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail("액션 제안 오류: " + ex.Message));
}
}
}

View File

@@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
public static class TaskDecomposer
{
private static readonly Regex StepPattern = new Regex("(?:^|\\n)\\s*(?:(?:Step\\s*)?(\\d+)[.):\\-]\\s*)(.+?)(?=\\n|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static List<string> ExtractSteps(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return new List<string>();
}
MatchCollection matchCollection = StepPattern.Matches(text);
if (matchCollection.Count < 2)
{
return new List<string>();
}
List<string> list = new List<string>();
int num = 0;
foreach (Match item in matchCollection)
{
if (int.TryParse(item.Groups[1].Value, out var result) && (result == num + 1 || num == 0))
{
string input = item.Groups[2].Value.Trim();
input = Regex.Replace(input, "\\*\\*(.+?)\\*\\*", "$1");
input = Regex.Replace(input, "\\*(.+?)\\*", "$1");
input = Regex.Replace(input, "`(.+?)`", "$1");
input = Regex.Replace(input, "\\[(.+?)\\]\\(.+?\\)", "$1");
input = input.TrimEnd(':', ' ');
if (input.Length >= 3 && input.Length <= 300)
{
list.Add(input);
num = result;
}
}
}
return (list.Count >= 2) ? list.Take(20).ToList() : new List<string>();
}
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++)
{
string text = steps[i].ToLowerInvariant();
string value = toolName.ToLowerInvariant();
string summary = toolSummary.ToLowerInvariant();
if (text.Contains(value) || ContainsKeywordOverlap(text, summary))
{
return i;
}
}
return Math.Min(lastStep + 1, steps.Count - 1);
}
private static bool ContainsKeywordOverlap(string stepText, string summary)
{
if (string.IsNullOrEmpty(summary))
{
return false;
}
IEnumerable<string> source = (from w in summary.Split(' ', '/', '\\', '.', ',', ':', ';', '(', ')')
where w.Length >= 3
select w).Take(5);
return source.Any((string w) => stepText.Contains(w));
}
}

View File

@@ -0,0 +1,310 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public class TaskTrackerTool : IAgentTool
{
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; }
}
private const string TaskFileName = ".ax/tasks.json";
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Action: scan, add, list, done"
};
int num = 4;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "scan";
span[1] = "add";
span[2] = "list";
span[3] = "done";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["title"] = new ToolProperty
{
Type = "string",
Description = "Task title (for 'add')"
};
dictionary["priority"] = new ToolProperty
{
Type = "string",
Description = "Priority: high, medium, low (for 'add', default: medium)"
};
dictionary["id"] = new ToolProperty
{
Type = "integer",
Description = "Task ID (for 'done')"
};
dictionary["extensions"] = new ToolProperty
{
Type = "string",
Description = "File extensions to scan, comma-separated (default: .cs,.py,.js,.ts,.java,.cpp,.c)"
};
toolParameterSchema.Properties = dictionary;
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "action";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string text = args.GetProperty("action").GetString() ?? "";
try
{
if (1 == 0)
{
}
Task<ToolResult> result = text 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: " + text)),
};
if (1 == 0)
{
}
return result;
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail("태스크 추적 오류: " + ex.Message));
}
}
private static ToolResult ScanTodos(JsonElement args, AgentContext context)
{
JsonElement value;
string text = (args.TryGetProperty("extensions", out value) ? (value.GetString() ?? ".cs,.py,.js,.ts,.java,.cpp,.c") : ".cs,.py,.js,.ts,.java,.cpp,.c");
HashSet<string> hashSet = new HashSet<string>(from s in text.Split(',')
select s.Trim().StartsWith('.') ? s.Trim() : ("." + s.Trim()), StringComparer.OrdinalIgnoreCase);
string[] array = new string[5] { "TODO", "FIXME", "HACK", "BUG", "XXX" };
List<(string, int, string, string)> list = new List<(string, int, string, string)>();
string workFolder = context.WorkFolder;
if (!Directory.Exists(workFolder))
{
return ToolResult.Fail("작업 폴더 없음: " + workFolder);
}
foreach (string item5 in Directory.EnumerateFiles(workFolder, "*", SearchOption.AllDirectories))
{
if (!hashSet.Contains(Path.GetExtension(item5)) || item5.Contains("bin") || item5.Contains("obj") || item5.Contains("node_modules"))
{
continue;
}
int num = 0;
foreach (string item6 in File.ReadLines(item5))
{
num++;
string[] array2 = array;
foreach (string text2 in array2)
{
int num3 = item6.IndexOf(text2, StringComparison.OrdinalIgnoreCase);
if (num3 >= 0)
{
string text3 = item6;
int num4 = num3 + text2.Length;
string text4 = text3.Substring(num4, text3.Length - num4).TrimStart(':', ' ');
if (text4.Length > 100)
{
text4 = text4.Substring(0, 100) + "...";
}
string relativePath = Path.GetRelativePath(workFolder, item5);
list.Add((relativePath, num, text2, text4));
break;
}
}
if (list.Count >= 200)
{
break;
}
}
if (list.Count < 200)
{
continue;
}
break;
}
if (list.Count == 0)
{
return ToolResult.Ok("TODO/FIXME 코멘트가 발견되지 않았습니다.");
}
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(15, 1, stringBuilder2);
handler.AppendLiteral("발견된 TODO 코멘트: ");
handler.AppendFormatted(list.Count);
handler.AppendLiteral("개");
stringBuilder3.AppendLine(ref handler);
foreach (var item7 in list)
{
string item = item7.Item1;
int item2 = item7.Item2;
string item3 = item7.Item3;
string item4 = item7.Item4;
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 4, stringBuilder2);
handler.AppendLiteral(" [");
handler.AppendFormatted(item3);
handler.AppendLiteral("] ");
handler.AppendFormatted(item);
handler.AppendLiteral(":");
handler.AppendFormatted(item2);
handler.AppendLiteral(" — ");
handler.AppendFormatted(item4);
stringBuilder4.AppendLine(ref handler);
}
return ToolResult.Ok(stringBuilder.ToString());
}
private static ToolResult AddTask(JsonElement args, AgentContext context)
{
JsonElement value;
string text = (args.TryGetProperty("title", out value) ? (value.GetString() ?? "") : "");
if (string.IsNullOrEmpty(text))
{
return ToolResult.Fail("'title'이 필요합니다.");
}
JsonElement value2;
string text2 = (args.TryGetProperty("priority", out value2) ? (value2.GetString() ?? "medium") : "medium");
List<TaskItem> list = LoadTasks(context);
int num = ((list.Count > 0) ? list.Max((TaskItem t2) => t2.Id) : 0);
list.Add(new TaskItem
{
Id = num + 1,
Title = text,
Priority = text2,
Created = DateTime.Now.ToString("yyyy-MM-dd HH:mm"),
Done = false
});
SaveTasks(context, list);
return ToolResult.Ok($"태스크 추가: #{num + 1} — {text} (우선순위: {text2})");
}
private static ToolResult ListTasks(AgentContext context)
{
List<TaskItem> list = LoadTasks(context);
if (list.Count == 0)
{
return ToolResult.Ok("등록된 태스크가 없습니다.");
}
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder2);
handler.AppendLiteral("태스크 목록 (");
handler.AppendFormatted(list.Count);
handler.AppendLiteral("개):");
stringBuilder3.AppendLine(ref handler);
foreach (TaskItem item in from t in list
orderby t.Done, (!(t.Priority == "high")) ? ((t.Priority == "medium") ? 1 : 2) : 0 descending
select t)
{
string value = (item.Done ? "✓" : "○");
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(11, 5, stringBuilder2);
handler.AppendLiteral(" ");
handler.AppendFormatted(value);
handler.AppendLiteral(" #");
handler.AppendFormatted(item.Id);
handler.AppendLiteral(" [");
handler.AppendFormatted(item.Priority);
handler.AppendLiteral("] ");
handler.AppendFormatted(item.Title);
handler.AppendLiteral(" (");
handler.AppendFormatted(item.Created);
handler.AppendLiteral(")");
stringBuilder4.AppendLine(ref handler);
}
return ToolResult.Ok(stringBuilder.ToString());
}
private static ToolResult MarkDone(JsonElement args, AgentContext context)
{
if (!args.TryGetProperty("id", out var value))
{
return ToolResult.Fail("'id'가 필요합니다.");
}
int id = value.GetInt32();
List<TaskItem> list = LoadTasks(context);
TaskItem taskItem = list.FirstOrDefault((TaskItem t) => t.Id == id);
if (taskItem == null)
{
return ToolResult.Fail($"태스크 #{id}를 찾을 수 없습니다.");
}
taskItem.Done = true;
SaveTasks(context, list);
return ToolResult.Ok($"태스크 #{id} 완료: {taskItem.Title}");
}
private static List<TaskItem> LoadTasks(AgentContext context)
{
string path = Path.Combine(context.WorkFolder, ".ax/tasks.json");
if (!File.Exists(path))
{
return new List<TaskItem>();
}
string json = File.ReadAllText(path);
return JsonSerializer.Deserialize<List<TaskItem>>(json) ?? new List<TaskItem>();
}
private static void SaveTasks(AgentContext context, List<TaskItem> tasks)
{
string path = Path.Combine(context.WorkFolder, ".ax/tasks.json");
string directoryName = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directoryName) && !Directory.Exists(directoryName))
{
Directory.CreateDirectory(directoryName);
}
string contents = JsonSerializer.Serialize(tasks, new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(path, contents);
}
}

View File

@@ -0,0 +1,3 @@
namespace AxCopilot.Services.Agent;
public record TemplateMood(string Key, string Label, string Icon, string Description);

View File

@@ -0,0 +1,205 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["template_path"] = new ToolProperty
{
Type = "string",
Description = "Path to template file (.html, .md, .txt). Relative to work folder."
},
["template_text"] = new ToolProperty
{
Type = "string",
Description = "Inline template text (used if template_path is not provided)."
},
["variables"] = new ToolProperty
{
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 ToolProperty
{
Type = "string",
Description = "Output file path. If not provided, returns rendered text."
}
}
};
int num = 1;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
CollectionsMarshal.AsSpan(list)[0] = "variables";
obj.Required = list;
return obj;
}
}
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()))
{
string 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 File.ReadAllTextAsync(templatePath, ct);
}
else
{
if (!args.TryGetProperty("template_text", out var ttEl) || string.IsNullOrEmpty(ttEl.GetString()))
{
return ToolResult.Fail("template_path 또는 template_text가 필요합니다.");
}
template = ttEl.GetString();
}
if (!args.TryGetProperty("variables", out var varsEl))
{
return ToolResult.Fail("variables가 필요합니다.");
}
try
{
string rendered = Render(template, varsEl);
if (args.TryGetProperty("output_path", out var opEl) && !string.IsNullOrEmpty(opEl.GetString()))
{
string 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);
}
string dir = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
await File.WriteAllTextAsync(outputPath, rendered, Encoding.UTF8, ct);
return ToolResult.Ok($"✅ 템플릿 렌더링 완료: {Path.GetFileName(outputPath)} ({rendered.Length:N0}자)", outputPath);
}
if (rendered.Length > 4000)
{
return ToolResult.Ok($"✅ 렌더링 완료 ({rendered.Length:N0}자):\n{rendered.Substring(0, 3900)}...\n[이하 생략]");
}
return ToolResult.Ok($"✅ 렌더링 완료 ({rendered.Length:N0}자):\n{rendered}");
}
catch (Exception ex)
{
return ToolResult.Fail("템플릿 렌더링 실패: " + ex.Message);
}
}
internal static string Render(string template, JsonElement variables)
{
string input = template;
input = Regex.Replace(input, "\\{\\{#(\\w+)\\}\\}(.*?)\\{\\{/\\1\\}\\}", delegate(Match match)
{
string value = match.Groups[1].Value;
string value2 = match.Groups[2].Value;
if (!variables.TryGetProperty(value, out var value3))
{
return "";
}
if (value3.ValueKind == JsonValueKind.Array)
{
StringBuilder stringBuilder = new StringBuilder();
int num = 0;
foreach (JsonElement item in value3.EnumerateArray())
{
string text = value2;
if (item.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty item2 in item.EnumerateObject())
{
text = text.Replace("{{" + item2.Name + "}}", item2.Value.ToString()).Replace("{{." + item2.Name + "}}", item2.Value.ToString());
}
}
else
{
text = text.Replace("{{.}}", item.ToString());
}
text = text.Replace("{{@index}}", (num + 1).ToString());
stringBuilder.Append(text);
num++;
}
return stringBuilder.ToString();
}
if (value3.ValueKind == JsonValueKind.True)
{
return RenderSimpleVars(value2, variables);
}
return (value3.ValueKind != JsonValueKind.False && value3.ValueKind != JsonValueKind.Null && value3.ValueKind != JsonValueKind.Undefined) ? RenderSimpleVars(value2, variables) : "";
}, RegexOptions.Singleline);
input = Regex.Replace(input, "\\{\\{\\^(\\w+)\\}\\}(.*?)\\{\\{/\\1\\}\\}", delegate(Match match)
{
string value = match.Groups[1].Value;
string value2 = match.Groups[2].Value;
if (!variables.TryGetProperty(value, out var value3))
{
return value2;
}
return (value3.ValueKind == JsonValueKind.False || value3.ValueKind == JsonValueKind.Null || (value3.ValueKind == JsonValueKind.Array && value3.GetArrayLength() == 0) || (value3.ValueKind == JsonValueKind.String && string.IsNullOrEmpty(value3.GetString()))) ? value2 : "";
}, RegexOptions.Singleline);
return RenderSimpleVars(input, variables);
}
private static string RenderSimpleVars(string text, JsonElement variables)
{
return Regex.Replace(text, "\\{\\{(\\w+)\\}\\}", delegate(Match match)
{
string value = match.Groups[1].Value;
if (!variables.TryGetProperty(value, out var value2))
{
return match.Value;
}
JsonValueKind valueKind = value2.ValueKind;
if (1 == 0)
{
}
string result = valueKind switch
{
JsonValueKind.String => value2.GetString() ?? "",
JsonValueKind.Number => value2.ToString(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => value2.ToString(),
};
if (1 == 0)
{
}
return result;
});
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,385 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
namespace AxCopilot.Services.Agent;
public class TestLoopTool : IAgentTool
{
private class FailureDetail
{
public string TestName { get; set; } = "";
public string FilePath { get; set; } = "";
public int Line { get; set; }
public string Message { get; set; } = "";
}
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 ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["action"] = new ToolProperty
{
Type = "string",
Description = "수행할 작업: generate | run | analyze | auto_fix",
Enum = new List<string> { "generate", "run", "analyze", "auto_fix" }
},
["file_path"] = new ToolProperty
{
Type = "string",
Description = "대상 소스 파일 경로 (generate 시 필요)"
},
["test_output"] = new ToolProperty
{
Type = "string",
Description = "분석할 테스트 출력 (analyze 시 필요)"
}
},
Required = new List<string> { "action" }
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
JsonElement a;
string action = (args.TryGetProperty("action", out a) ? (a.GetString() ?? "") : "");
if (1 == 0)
{
}
ToolResult result = 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 중 하나여야 합니다."),
};
if (1 == 0)
{
}
return result;
}
private static ToolResult GenerateTestSuggestion(JsonElement args, AgentContext context)
{
JsonElement value;
string text = (args.TryGetProperty("file_path", out value) ? (value.GetString() ?? "") : "");
if (string.IsNullOrEmpty(text))
{
return ToolResult.Fail("file_path가 필요합니다.");
}
if (!Path.IsPathRooted(text) && !string.IsNullOrEmpty(context.WorkFolder))
{
text = Path.Combine(context.WorkFolder, text);
}
if (!File.Exists(text))
{
return ToolResult.Fail("파일 없음: " + text);
}
string text2 = Path.GetExtension(text).ToLowerInvariant();
if (1 == 0)
{
}
(string, string, string) tuple;
switch (text2)
{
case ".cs":
tuple = ("xUnit/NUnit/MSTest", ".cs", "ClassNameTests.cs");
break;
case ".py":
tuple = ("pytest", ".py", "test_module.py");
break;
case ".ts":
case ".tsx":
tuple = ("Jest/Vitest", ".test.ts", "Component.test.ts");
break;
case ".js":
case ".jsx":
tuple = ("Jest", ".test.js", "module.test.js");
break;
case ".java":
tuple = ("JUnit", ".java", "ClassTest.java");
break;
case ".go":
tuple = ("go test", "_test.go", "module_test.go");
break;
default:
tuple = ("unknown", text2, "test" + text2);
break;
}
if (1 == 0)
{
}
(string, string, string) tuple2 = tuple;
string item = tuple2.Item1;
string item2 = tuple2.Item2;
string item3 = tuple2.Item3;
string text3 = File.ReadAllText(text);
int value2 = text3.Split('\n').Length;
return ToolResult.Ok($"테스트 생성 제안:\n 대상 파일: {Path.GetFileName(text)} ({value2}줄)\n 감지된 프레임워크: {item}\n 테스트 파일 명명: {item3}\n 테스트 파일 확장자: {item2}\n\nfile_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
{
ProcessStartInfo psi = new ProcessStartInfo
{
FileName = cmd,
Arguments = cmdArgs,
WorkingDirectory = context.WorkFolder,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using Process proc = Process.Start(psi);
if (proc == null)
{
return ToolResult.Fail("테스트 프로세스 시작 실패");
}
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(120.0));
string stdout = await proc.StandardOutput.ReadToEndAsync(cts.Token);
string stderr = await proc.StandardError.ReadToEndAsync(cts.Token);
await proc.WaitForExitAsync(cts.Token);
StringBuilder sb = new StringBuilder();
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(24, 1, stringBuilder);
handler.AppendLiteral("테스트 실행 결과 (exit code: ");
handler.AppendFormatted(proc.ExitCode);
handler.AppendLiteral("):");
stringBuilder2.AppendLine(ref handler);
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(5, 2, stringBuilder);
handler.AppendLiteral("명령: ");
handler.AppendFormatted(cmd);
handler.AppendLiteral(" ");
handler.AppendFormatted(cmdArgs);
stringBuilder3.AppendLine(ref handler);
sb.AppendLine();
if (!string.IsNullOrWhiteSpace(stdout))
{
sb.AppendLine(stdout);
}
if (!string.IsNullOrWhiteSpace(stderr))
{
stringBuilder = sb;
StringBuilder stringBuilder4 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder);
handler.AppendLiteral("[STDERR]\n");
handler.AppendFormatted(stderr);
stringBuilder4.AppendLine(ref handler);
}
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)
{
JsonElement value;
string text = (args.TryGetProperty("test_output", out value) ? (value.GetString() ?? "") : "");
if (string.IsNullOrEmpty(text))
{
return ToolResult.Fail("test_output이 필요합니다.");
}
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("테스트 결과 분석:");
string[] source = text.Split('\n');
int num = source.Count((string l) => l.Contains("FAIL", StringComparison.OrdinalIgnoreCase) || l.Contains("FAILED"));
int value2 = source.Count((string l) => l.Contains("PASS", StringComparison.OrdinalIgnoreCase) || l.Contains("PASSED"));
List<string> list = source.Where((string l) => l.Contains("Error", StringComparison.OrdinalIgnoreCase) || l.Contains("Exception")).Take(10).ToList();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(14, 2, stringBuilder2);
handler.AppendLiteral(" 통과: ");
handler.AppendFormatted(value2);
handler.AppendLiteral("개, 실패: ");
handler.AppendFormatted(num);
handler.AppendLiteral("개");
stringBuilder3.AppendLine(ref handler);
if (list.Count > 0)
{
stringBuilder.AppendLine("\n주요 오류:");
foreach (string item in list)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(2, 1, stringBuilder2);
handler.AppendLiteral(" ");
handler.AppendFormatted(item.Trim());
stringBuilder4.AppendLine(ref handler);
}
}
if (num > 0)
{
stringBuilder.AppendLine("\n다음 단계: 실패한 테스트를 확인하고 관련 코드를 수정한 후 test_loop action=\"run\"으로 다시 실행하세요.");
}
else
{
stringBuilder.AppendLine("\n모든 테스트가 통과했습니다.");
}
return ToolResult.Ok(stringBuilder.ToString());
}
private static async Task<ToolResult> AutoFixAsync(AgentContext context, CancellationToken ct)
{
ToolResult runResult = await RunTestsAsync(context, ct);
string output = runResult.Output;
string[] lines = output.Split('\n');
int failedCount = lines.Count((string 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모든 테스트가 통과했습니다. 수정 루프를 종료하세요.");
}
StringBuilder sb = new StringBuilder();
sb.AppendLine("[AUTO_FIX: FAILURES_DETECTED]");
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(10, 1, stringBuilder);
handler.AppendLiteral("실패 테스트 수: ");
handler.AppendFormatted(failedCount);
stringBuilder2.AppendLine(ref handler);
sb.AppendLine();
List<FailureDetail> errors = ExtractFailureDetails(lines);
if (errors.Count > 0)
{
sb.AppendLine("## 실패 상세:");
foreach (FailureDetail err in errors.Take(10))
{
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder);
handler.AppendLiteral("- 테스트: ");
handler.AppendFormatted(err.TestName);
stringBuilder3.AppendLine(ref handler);
if (!string.IsNullOrEmpty(err.FilePath))
{
stringBuilder = sb;
StringBuilder stringBuilder4 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(7, 2, stringBuilder);
handler.AppendLiteral(" 파일: ");
handler.AppendFormatted(err.FilePath);
handler.AppendLiteral(":");
handler.AppendFormatted(err.Line);
stringBuilder4.AppendLine(ref handler);
}
stringBuilder = sb;
StringBuilder stringBuilder5 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(6, 1, stringBuilder);
handler.AppendLiteral(" 오류: ");
handler.AppendFormatted(err.Message);
stringBuilder5.AppendLine(ref handler);
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. 모든 테스트가 통과할 때까지 반복하세요");
int maxIter = (Application.Current as App)?.SettingsService?.Settings.Llm.MaxTestFixIterations ?? 5;
stringBuilder = sb;
StringBuilder stringBuilder6 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(36, 1, stringBuilder);
handler.AppendLiteral("\n※ 최대 수정 반복 횟수: ");
handler.AppendFormatted(maxIter);
handler.AppendLiteral("회. 초과 시 사용자에게 보고하세요.");
stringBuilder6.AppendLine(ref handler);
sb.AppendLine("\n## 전체 테스트 출력:");
string truncated = ((output.Length > 3000) ? (output.Substring(0, 3000) + "\n... (출력 일부 생략)") : output);
sb.AppendLine(truncated);
return ToolResult.Ok(sb.ToString());
}
private static List<FailureDetail> ExtractFailureDetails(string[] lines)
{
List<FailureDetail> list = new List<FailureDetail>();
FailureDetail failureDetail = null;
for (int i = 0; i < lines.Length; i++)
{
string text = lines[i].Trim();
if (text.StartsWith("Failed ", StringComparison.OrdinalIgnoreCase) || text.Contains("FAIL!", StringComparison.OrdinalIgnoreCase))
{
failureDetail = new FailureDetail
{
TestName = text
};
list.Add(failureDetail);
}
else if (text.StartsWith("FAILED ", StringComparison.OrdinalIgnoreCase))
{
FailureDetail failureDetail2 = new FailureDetail();
string text2 = text;
failureDetail2.TestName = text2.Substring(7, text2.Length - 7).Trim();
failureDetail = failureDetail2;
list.Add(failureDetail);
}
else if (failureDetail != null && string.IsNullOrEmpty(failureDetail.FilePath))
{
Match match = Regex.Match(text, "([^\\s]+\\.\\w+)[:\\(](\\d+)");
if (match.Success)
{
failureDetail.FilePath = match.Groups[1].Value;
failureDetail.Line = int.Parse(match.Groups[2].Value);
}
}
else if (failureDetail != null && string.IsNullOrEmpty(failureDetail.Message) && (text.Contains("Assert", StringComparison.OrdinalIgnoreCase) || text.Contains("Error", StringComparison.OrdinalIgnoreCase) || text.Contains("Exception", StringComparison.OrdinalIgnoreCase)))
{
failureDetail.Message = text;
}
}
return list;
}
private static (string? Cmd, string Args) DetectTestCommand(string workFolder)
{
if (Directory.EnumerateFiles(workFolder, "*.csproj", SearchOption.AllDirectories).Any() || Directory.EnumerateFiles(workFolder, "*.sln", SearchOption.TopDirectoryOnly).Any())
{
return (Cmd: "dotnet", Args: "test --no-build --verbosity normal");
}
if (File.Exists(Path.Combine(workFolder, "pytest.ini")) || File.Exists(Path.Combine(workFolder, "setup.py")) || Directory.EnumerateFiles(workFolder, "test_*.py", SearchOption.AllDirectories).Any())
{
return (Cmd: "pytest", Args: "--tb=short -q");
}
if (File.Exists(Path.Combine(workFolder, "package.json")))
{
return (Cmd: "npm", Args: "test -- --passWithNoTests");
}
if (Directory.EnumerateFiles(workFolder, "*_test.go", SearchOption.AllDirectories).Any())
{
return (Cmd: "go", Args: "test ./...");
}
return (Cmd: null, Args: "");
}
}

View File

@@ -0,0 +1,318 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> obj = new Dictionary<string, ToolProperty>
{
["source"] = new ToolProperty
{
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 ToolProperty
{
Type = "integer",
Description = "Maximum summary length in characters. Default: 500"
}
};
ToolProperty obj2 = new ToolProperty
{
Type = "string",
Description = "Summary style: bullet (bullet points), paragraph (flowing text), executive (key conclusions + action items), technical (detailed with terminology). Default: bullet"
};
int num = 4;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "bullet";
span[1] = "paragraph";
span[2] = "executive";
span[3] = "technical";
obj2.Enum = list;
obj["style"] = obj2;
obj["language"] = new ToolProperty
{
Type = "string",
Description = "Output language: ko (Korean), en (English). Default: ko"
};
obj["focus"] = new ToolProperty
{
Type = "string",
Description = "Optional focus area or keywords to emphasize in the summary."
};
obj["sections"] = new ToolProperty
{
Type = "boolean",
Description = "If true, provide section-by-section summary instead of one overall summary. Default: false"
};
toolParameterSchema.Properties = obj;
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "source";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
string source = args.GetProperty("source").GetString() ?? "";
JsonElement mlEl;
int ml;
int maxLength = ((args.TryGetProperty("max_length", out mlEl) && mlEl.TryGetInt32(out ml)) ? ml : 500);
JsonElement stEl;
string style = (args.TryGetProperty("style", out stEl) ? (stEl.GetString() ?? "bullet") : "bullet");
JsonElement langEl;
string language = (args.TryGetProperty("language", out langEl) ? (langEl.GetString() ?? "ko") : "ko");
JsonElement focEl;
string focus = (args.TryGetProperty("focus", out focEl) ? (focEl.GetString() ?? "") : "");
JsonElement secEl;
bool bySections = args.TryGetProperty("sections", out secEl) && secEl.GetBoolean();
string text;
if (LooksLikeFilePath(source))
{
string fullPath = FileReadTool.ResolvePath(source, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
{
return ToolResult.Fail("경로 접근 차단: " + fullPath);
}
if (!File.Exists(fullPath))
{
return ToolResult.Fail("파일 없음: " + fullPath);
}
text = await File.ReadAllTextAsync(fullPath, ct);
if (fullPath.EndsWith(".html", StringComparison.OrdinalIgnoreCase) || fullPath.EndsWith(".htm", StringComparison.OrdinalIgnoreCase))
{
text = StripHtmlTags(text);
}
}
else
{
text = source;
}
if (string.IsNullOrWhiteSpace(text))
{
return ToolResult.Fail("요약할 텍스트가 비어있습니다.");
}
int charCount = text.Length;
int lineCount = text.Split('\n').Length;
int wordCount = EstimateWordCount(text);
if (charCount <= maxLength)
{
return ToolResult.Ok($"\ud83d\udcdd 텍스트가 이미 요약 기준 이하입니다 ({charCount}자).\n\n{text}");
}
List<string> chunks = ChunkText(text, 3000);
List<string> chunkSummaries = new List<string>();
foreach (string chunk in chunks)
{
string summary = ExtractKeyContent(chunk, maxLength / chunks.Count, style, focus);
chunkSummaries.Add(summary);
}
StringBuilder sb = new StringBuilder();
StringBuilder stringBuilder = sb;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(25, 3, stringBuilder);
handler.AppendLiteral("\ud83d\udcdd 텍스트 요약 (원문: ");
handler.AppendFormatted(charCount, "N0");
handler.AppendLiteral("자, ");
handler.AppendFormatted(lineCount);
handler.AppendLiteral("줄, ~");
handler.AppendFormatted(wordCount);
handler.AppendLiteral("단어)");
stringBuilder2.AppendLine(ref handler);
sb.AppendLine();
if (bySections && chunks.Count > 1)
{
for (int i = 0; i < chunkSummaries.Count; i++)
{
stringBuilder = sb;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(8, 2, stringBuilder);
handler.AppendLiteral("### 섹션 ");
handler.AppendFormatted(i + 1);
handler.AppendLiteral("/");
handler.AppendFormatted(chunkSummaries.Count);
stringBuilder3.AppendLine(ref handler);
sb.AppendLine(chunkSummaries[i]);
sb.AppendLine();
}
}
else
{
string combined = string.Join("\n", chunkSummaries);
sb.AppendLine(FormatSummary(combined, style, language, focus));
}
string result = sb.ToString();
if (result.Length > maxLength + 500)
{
result = result.Substring(0, 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 && Regex.IsMatch(s, "\\.\\w{1,5}$"))
{
return true;
}
return false;
}
private static string StripHtmlTags(string html)
{
string input = Regex.Replace(html, "<script[^>]*>.*?</script>", "", RegexOptions.Singleline);
input = Regex.Replace(input, "<style[^>]*>.*?</style>", "", RegexOptions.Singleline);
input = Regex.Replace(input, "<[^>]+>", " ");
input = WebUtility.HtmlDecode(input);
return Regex.Replace(input, "\\s+", " ").Trim();
}
private static int EstimateWordCount(string text)
{
int num = text.Count((char c) => c == ' ');
int num2 = text.Count((char c) => c >= '가' && c <= '힣');
return num + 1 + num2 / 3;
}
private static List<string> ChunkText(string text, int chunkSize)
{
List<string> list = new List<string>();
string[] array = text.Split('\n');
StringBuilder stringBuilder = new StringBuilder();
string[] array2 = array;
foreach (string text2 in array2)
{
if (stringBuilder.Length + text2.Length > chunkSize && stringBuilder.Length > 0)
{
list.Add(stringBuilder.ToString());
stringBuilder.Clear();
}
stringBuilder.AppendLine(text2);
}
if (stringBuilder.Length > 0)
{
list.Add(stringBuilder.ToString());
}
return list;
}
private static string ExtractKeyContent(string text, int targetLength, string style, string focus)
{
List<string> sentences = (from s in Regex.Split(text, "(?<=[.!?。\\n])\\s+")
where s.Trim().Length > 10
select s).ToList();
if (sentences.Count == 0)
{
return (text.Length > targetLength) ? text.Substring(0, targetLength) : text;
}
List<(string, double)> list = (from x in sentences.Select(delegate(string s)
{
double num2 = 0.0;
if (s.Length > 20 && s.Length < 200)
{
num2 += 1.0;
}
if (Regex.IsMatch(s, "\\d+"))
{
num2 += 0.5;
}
if (!string.IsNullOrEmpty(focus) && s.Contains(focus, StringComparison.OrdinalIgnoreCase))
{
num2 += 2.0;
}
int num3 = sentences.IndexOf(s);
if (num3 == 0 || num3 == sentences.Count - 1)
{
num2 += 1.0;
}
if (num3 < 3)
{
num2 += 0.5;
}
if (s.Contains("결론") || s.Contains("요약") || s.Contains("핵심") || s.Contains("중요") || s.Contains("결과") || s.Contains("therefore") || s.Contains("conclusion") || s.Contains("key"))
{
num2 += 1.5;
}
return (Sentence: s.Trim(), Score: num2);
})
orderby x.Score descending
select x).ToList();
List<string> list2 = new List<string>();
int num = 0;
foreach (var item2 in list)
{
string item = item2.Item1;
if (num + item.Length > targetLength && list2.Count > 0)
{
break;
}
list2.Add(item);
num += item.Length;
}
list2.Sort((string a, string b) => text.IndexOf(a).CompareTo(text.IndexOf(b)));
return string.Join("\n", list2);
}
private static string FormatSummary(string content, string style, string language, string focus)
{
switch (style)
{
case "bullet":
{
string[] source = content.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return string.Join("\n", source.Select((string l) => (l.StartsWith("•") || l.StartsWith("-")) ? l : ("• " + l)));
}
case "executive":
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("**핵심 요약**");
stringBuilder.AppendLine(content);
if (!string.IsNullOrEmpty(focus))
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(16, 1, stringBuilder2);
handler.AppendLiteral("\n**주요 관심 영역 (");
handler.AppendFormatted(focus);
handler.AppendLiteral(")**");
stringBuilder2.AppendLine(ref handler);
}
return stringBuilder.ToString();
}
case "technical":
return "**기술 요약**\n" + content;
default:
return content.Replace("\n\n", "\n").Replace("\n", " ").Trim();
}
}
}

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace AxCopilot.Services.Agent;
public class ToolParameterSchema
{
[JsonPropertyName("type")]
public string Type { get; init; } = "object";
[JsonPropertyName("properties")]
public Dictionary<string, ToolProperty> Properties { get; init; } = new Dictionary<string, ToolProperty>();
[JsonPropertyName("required")]
public List<string> Required { get; init; } = new List<string>();
}

View File

@@ -0,0 +1,21 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace AxCopilot.Services.Agent;
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; }
[JsonPropertyName("items")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ToolProperty? Items { get; init; }
}

View File

@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
public class ToolRegistry : IDisposable
{
private readonly Dictionary<string, IAgentTool> _tools = new Dictionary<string, IAgentTool>(StringComparer.OrdinalIgnoreCase);
private readonly List<IDisposable> _ownedResources = new List<IDisposable>();
public IReadOnlyCollection<IAgentTool> All => _tools.Values;
public IAgentTool? Get(string name)
{
IAgentTool value;
return _tools.TryGetValue(name, out value) ? value : null;
}
public void Register(IAgentTool tool)
{
_tools[tool.Name] = tool;
}
public async Task<int> RegisterMcpToolsAsync(IEnumerable<McpServerEntry>? servers, CancellationToken ct = default(CancellationToken))
{
if (servers == null)
{
return 0;
}
int registered = 0;
foreach (McpServerEntry 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;
}
McpClientService client = new McpClientService(server);
if (!(await client.ConnectAsync(ct).ConfigureAwait(continueOnCapturedContext: false)))
{
client.Dispose();
continue;
}
_ownedResources.Add(client);
foreach (McpToolDefinition def in client.Tools)
{
Register(new McpTool(client, def));
registered++;
}
}
return registered;
}
public IReadOnlyCollection<IAgentTool> GetActiveTools(IEnumerable<string>? disabledNames = null)
{
if (disabledNames == null)
{
return All;
}
HashSet<string> disabled = new HashSet<string>(disabledNames, StringComparer.OrdinalIgnoreCase);
if (disabled.Count == 0)
{
return All;
}
return _tools.Values.Where((IAgentTool t) => !disabled.Contains(t.Name)).ToList().AsReadOnly();
}
public void Dispose()
{
foreach (IAgentTool value in _tools.Values)
{
if (value is IDisposable disposable)
{
disposable.Dispose();
}
}
foreach (IDisposable ownedResource in _ownedResources)
{
ownedResource.Dispose();
}
_ownedResources.Clear();
_tools.Clear();
}
public static ToolRegistry CreateDefault()
{
ToolRegistry toolRegistry = new ToolRegistry();
toolRegistry.Register(new FileReadTool());
toolRegistry.Register(new FileWriteTool());
toolRegistry.Register(new FileEditTool());
toolRegistry.Register(new GlobTool());
toolRegistry.Register(new GrepTool());
toolRegistry.Register(new ProcessTool());
toolRegistry.Register(new FolderMapTool());
toolRegistry.Register(new DocumentReaderTool());
toolRegistry.Register(new ExcelSkill());
toolRegistry.Register(new DocxSkill());
toolRegistry.Register(new CsvSkill());
toolRegistry.Register(new MarkdownSkill());
toolRegistry.Register(new HtmlSkill());
toolRegistry.Register(new ChartSkill());
toolRegistry.Register(new BatchSkill());
toolRegistry.Register(new PptxSkill());
toolRegistry.Register(new DocumentPlannerTool());
toolRegistry.Register(new DocumentAssemblerTool());
toolRegistry.Register(new DocumentReviewTool());
toolRegistry.Register(new FormatConvertTool());
toolRegistry.Register(new DevEnvDetectTool());
toolRegistry.Register(new BuildRunTool());
toolRegistry.Register(new GitTool());
toolRegistry.Register(new LspTool());
toolRegistry.Register(new SubAgentTool());
toolRegistry.Register(new WaitAgentsTool());
toolRegistry.Register(new CodeSearchTool());
toolRegistry.Register(new TestLoopTool());
toolRegistry.Register(new CodeReviewTool());
toolRegistry.Register(new ProjectRuleTool());
toolRegistry.Register(new SkillManagerTool());
toolRegistry.Register(new MemoryTool());
toolRegistry.Register(new JsonTool());
toolRegistry.Register(new RegexTool());
toolRegistry.Register(new DiffTool());
toolRegistry.Register(new ClipboardTool());
toolRegistry.Register(new NotifyTool());
toolRegistry.Register(new EnvTool());
toolRegistry.Register(new ZipTool());
toolRegistry.Register(new HttpTool());
toolRegistry.Register(new SqlTool());
toolRegistry.Register(new Base64Tool());
toolRegistry.Register(new HashTool());
toolRegistry.Register(new DateTimeTool());
toolRegistry.Register(new SnippetRunnerTool());
toolRegistry.Register(new DataPivotTool());
toolRegistry.Register(new TemplateRenderTool());
toolRegistry.Register(new TextSummarizeTool());
toolRegistry.Register(new FileWatchTool());
toolRegistry.Register(new ImageAnalyzeTool());
toolRegistry.Register(new FileManageTool());
toolRegistry.Register(new FileInfoTool());
toolRegistry.Register(new MultiReadTool());
toolRegistry.Register(new UserAskTool());
toolRegistry.Register(new OpenExternalTool());
toolRegistry.Register(new MathTool());
toolRegistry.Register(new XmlTool());
toolRegistry.Register(new EncodingTool());
toolRegistry.Register(new TaskTrackerTool());
toolRegistry.Register(new SuggestActionsTool());
toolRegistry.Register(new DiffPreviewTool());
toolRegistry.Register(new CheckpointTool());
toolRegistry.Register(new PlaybookTool());
return toolRegistry;
}
}

View File

@@ -0,0 +1,32 @@
namespace AxCopilot.Services.Agent;
public class ToolResult
{
public bool Success { get; init; }
public string Output { get; init; } = "";
public string? FilePath { get; init; }
public string? Error { get; init; }
public static ToolResult Ok(string output, string? filePath = null)
{
return new ToolResult
{
Success = true,
Output = output,
FilePath = filePath
};
}
public static ToolResult Fail(string error)
{
return new ToolResult
{
Success = false,
Output = error,
Error = error
};
}
}

View File

@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema obj = new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["question"] = new ToolProperty
{
Type = "string",
Description = "The question to ask the user"
},
["options"] = new ToolProperty
{
Type = "array",
Description = "Optional list of choices for the user (e.g. ['Option A', 'Option B'])",
Items = new ToolProperty
{
Type = "string",
Description = "Choice option"
}
},
["default_value"] = new ToolProperty
{
Type = "string",
Description = "Default value if user doesn't specify"
}
}
};
int num = 1;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
CollectionsMarshal.AsSpan(list)[0] = "question";
obj.Required = list;
return obj;
}
}
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string question = args.GetProperty("question").GetString() ?? "";
JsonElement dv;
string defaultVal = (args.TryGetProperty("default_value", out dv) ? (dv.GetString() ?? "") : "");
List<string> options = new List<string>();
if (args.TryGetProperty("options", out var opts) && opts.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in opts.EnumerateArray())
{
string s = item.GetString();
if (!string.IsNullOrEmpty(s))
{
options.Add(s);
}
}
}
if (context.UserAskCallback != null)
{
try
{
string response = await context.UserAskCallback(question, options, defaultVal);
if (response == null)
{
return ToolResult.Fail("사용자가 응답을 취소했습니다.");
}
return ToolResult.Ok("사용자 응답: " + response);
}
catch (OperationCanceledException)
{
return ToolResult.Fail("사용자가 응답을 취소했습니다.");
}
catch (Exception ex2)
{
return ToolResult.Fail("사용자 입력 오류: " + ex2.Message);
}
}
if (context.UserDecision != null)
{
try
{
string prompt = question;
if (!string.IsNullOrEmpty(defaultVal))
{
prompt = prompt + "\n(기본값: " + defaultVal + ")";
}
List<string> effectiveOptions = ((options.Count > 0) ? options : new List<string> { "확인" });
string response2 = await context.UserDecision(prompt, effectiveOptions);
if (string.IsNullOrEmpty(response2) && !string.IsNullOrEmpty(defaultVal))
{
response2 = defaultVal;
}
return ToolResult.Ok("사용자 응답: " + response2);
}
catch (OperationCanceledException)
{
return ToolResult.Fail("사용자가 응답을 취소했습니다.");
}
catch (Exception ex4)
{
return ToolResult.Fail("사용자 입력 오류: " + ex4.Message);
}
}
return ToolResult.Fail("사용자 입력 콜백이 등록되지 않았습니다.");
}
}

View File

@@ -0,0 +1,53 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public class WaitAgentsTool : IAgentTool
{
public string Name => "wait_agents";
public string Description => "Wait for sub-agents and collect their results.\nYou may wait for all sub-agents or only specific ids.\nUse completed_only=true to collect only already finished sub-agents without blocking.";
public ToolParameterSchema Parameters => new ToolParameterSchema
{
Properties = new Dictionary<string, ToolProperty>
{
["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 List<string>()
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
List<string> ids = null;
if (args.TryGetProperty("ids", out var idsEl) && idsEl.ValueKind == JsonValueKind.Array)
{
ids = (from x in idsEl.EnumerateArray()
where x.ValueKind == JsonValueKind.String
select x.GetString() ?? "" into x
where !string.IsNullOrWhiteSpace(x)
select x).ToList();
}
JsonElement completedEl;
bool completedOnly = args.TryGetProperty("completed_only", out completedEl) && completedEl.ValueKind == JsonValueKind.True;
return ToolResult.Ok(await SubAgentTool.WaitAsync(ids, completedOnly, ct).ConfigureAwait(continueOnCapturedContext: false));
}
}

View File

@@ -0,0 +1,273 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Linq;
using System.Xml.XPath;
namespace AxCopilot.Services.Agent;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Action: parse, xpath, to_json, format"
};
int num = 4;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "parse";
span[1] = "xpath";
span[2] = "to_json";
span[3] = "format";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["path"] = new ToolProperty
{
Type = "string",
Description = "XML file path (optional if 'xml' is provided)"
};
dictionary["xml"] = new ToolProperty
{
Type = "string",
Description = "XML string (optional if 'path' is provided)"
};
dictionary["expression"] = new ToolProperty
{
Type = "string",
Description = "XPath expression (for 'xpath' action)"
};
toolParameterSchema.Properties = dictionary;
num = 1;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
CollectionsMarshal.AsSpan(list2)[0] = "action";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string text = args.GetProperty("action").GetString() ?? "";
JsonElement value;
string text2 = (args.TryGetProperty("xml", out value) ? (value.GetString() ?? "") : "");
JsonElement value2;
string text3 = (args.TryGetProperty("path", out value2) ? (value2.GetString() ?? "") : "");
JsonElement value3;
string xpath = (args.TryGetProperty("expression", out value3) ? (value3.GetString() ?? "") : "");
try
{
if (string.IsNullOrEmpty(text2) && !string.IsNullOrEmpty(text3))
{
string text4 = (Path.IsPathRooted(text3) ? text3 : Path.Combine(context.WorkFolder, text3));
if (!context.IsPathAllowed(text4))
{
return Task.FromResult(ToolResult.Fail("경로 접근 차단: " + text4));
}
if (!File.Exists(text4))
{
return Task.FromResult(ToolResult.Fail("파일 없음: " + text4));
}
text2 = File.ReadAllText(text4);
}
if (string.IsNullOrEmpty(text2))
{
return Task.FromResult(ToolResult.Fail("'xml' 또는 'path' 중 하나를 지정해야 합니다."));
}
XDocument doc = XDocument.Parse(text2);
if (1 == 0)
{
}
Task<ToolResult> result = text switch
{
"parse" => Task.FromResult(ParseSummary(doc)),
"xpath" => Task.FromResult(EvalXPath(doc, xpath)),
"to_json" => Task.FromResult(XmlToJson(doc)),
"format" => Task.FromResult(FormatXml(doc)),
_ => Task.FromResult(ToolResult.Fail("Unknown action: " + text)),
};
if (1 == 0)
{
}
return result;
}
catch (XmlException ex)
{
return Task.FromResult(ToolResult.Fail("XML 파싱 오류: " + ex.Message));
}
catch (Exception ex2)
{
return Task.FromResult(ToolResult.Fail("XML 처리 오류: " + ex2.Message));
}
}
private static ToolResult ParseSummary(XDocument doc)
{
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(6, 1, stringBuilder2);
handler.AppendLiteral("Root: ");
handler.AppendFormatted(doc.Root?.Name.LocalName ?? "(none)");
stringBuilder3.AppendLine(ref handler);
if (doc.Root != null)
{
XNamespace xNamespace = doc.Root.Name.Namespace;
if (!string.IsNullOrEmpty(xNamespace.NamespaceName))
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder2);
handler.AppendLiteral("Namespace: ");
handler.AppendFormatted(xNamespace.NamespaceName);
stringBuilder4.AppendLine(ref handler);
}
int value = doc.Descendants().Count();
int value2 = doc.Descendants().SelectMany((XElement e) => e.Attributes()).Count();
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(10, 1, stringBuilder2);
handler.AppendLiteral("Elements: ");
handler.AppendFormatted(value);
stringBuilder5.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(12, 1, stringBuilder2);
handler.AppendLiteral("Attributes: ");
handler.AppendFormatted(value2);
stringBuilder6.AppendLine(ref handler);
List<XElement> list = doc.Root.Elements().Take(20).ToList();
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(22, 1, stringBuilder2);
handler.AppendLiteral("Top-level children (");
handler.AppendFormatted(doc.Root.Elements().Count());
handler.AppendLiteral("):");
stringBuilder7.AppendLine(ref handler);
foreach (XElement item in list)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder8 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(16, 2, stringBuilder2);
handler.AppendLiteral(" <");
handler.AppendFormatted(item.Name.LocalName);
handler.AppendLiteral("> (");
handler.AppendFormatted(item.Elements().Count());
handler.AppendLiteral(" children)");
stringBuilder8.AppendLine(ref handler);
}
}
return ToolResult.Ok(stringBuilder.ToString());
}
private static ToolResult EvalXPath(XDocument doc, string xpath)
{
if (string.IsNullOrEmpty(xpath))
{
return ToolResult.Fail("XPath 'expression'이 필요합니다.");
}
List<XElement> list = doc.XPathSelectElements(xpath).Take(50).ToList();
if (list.Count == 0)
{
return ToolResult.Ok("매칭 노드 없음.");
}
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(8, 1, stringBuilder2);
handler.AppendLiteral("매칭: ");
handler.AppendFormatted(list.Count);
handler.AppendLiteral("개 노드");
stringBuilder2.AppendLine(ref handler);
foreach (XElement item in list)
{
string text = item.ToString();
if (text.Length > 500)
{
text = text.Substring(0, 500) + "...";
}
stringBuilder.AppendLine(text);
}
return ToolResult.Ok(stringBuilder.ToString());
}
private static ToolResult XmlToJson(XDocument doc)
{
string text = JsonSerializer.Serialize(XmlToDict(doc.Root), new JsonSerializerOptions
{
WriteIndented = true
});
if (text.Length > 50000)
{
text = text.Substring(0, 50000) + "\n... (truncated)";
}
return ToolResult.Ok(text);
}
private static Dictionary<string, object?> XmlToDict(XElement el)
{
Dictionary<string, object> dictionary = new Dictionary<string, object>();
foreach (XAttribute item in el.Attributes())
{
dictionary["@" + item.Name.LocalName] = item.Value;
}
List<IGrouping<string, XElement>> list = (from e in el.Elements()
group e by e.Name.LocalName).ToList();
foreach (IGrouping<string, XElement> item2 in list)
{
List<XElement> list2 = item2.ToList();
if (list2.Count == 1)
{
XElement xElement = list2[0];
dictionary[item2.Key] = (xElement.HasElements ? ((IEnumerable)XmlToDict(xElement)) : ((IEnumerable)xElement.Value));
}
else
{
dictionary[item2.Key] = ((IEnumerable<XElement>)list2).Select((Func<XElement, object>)((XElement c) => c.HasElements ? ((IEnumerable)XmlToDict(c)) : ((IEnumerable)c.Value))).ToList();
}
}
if (!el.HasElements && list.Count == 0 && !string.IsNullOrEmpty(el.Value))
{
dictionary["#text"] = el.Value;
}
return dictionary;
}
private static ToolResult FormatXml(XDocument doc)
{
StringBuilder stringBuilder = new StringBuilder();
using XmlWriter xmlWriter = XmlWriter.Create(stringBuilder, new XmlWriterSettings
{
Indent = true,
IndentChars = " ",
OmitXmlDeclaration = false
});
doc.WriteTo(xmlWriter);
xmlWriter.Flush();
string text = stringBuilder.ToString();
if (text.Length > 50000)
{
text = text.Substring(0, 50000) + "\n... (truncated)";
}
return ToolResult.Ok(text);
}
}

View File

@@ -0,0 +1,238 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace AxCopilot.Services.Agent;
public class ZipTool : IAgentTool
{
private const long MaxExtractSize = 524288000L;
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
{
get
{
ToolParameterSchema toolParameterSchema = new ToolParameterSchema();
Dictionary<string, ToolProperty> dictionary = new Dictionary<string, ToolProperty>();
ToolProperty obj = new ToolProperty
{
Type = "string",
Description = "Action to perform"
};
int num = 3;
List<string> list = new List<string>(num);
CollectionsMarshal.SetCount(list, num);
Span<string> span = CollectionsMarshal.AsSpan(list);
span[0] = "compress";
span[1] = "extract";
span[2] = "list";
obj.Enum = list;
dictionary["action"] = obj;
dictionary["zip_path"] = new ToolProperty
{
Type = "string",
Description = "Path to the zip file (to create or extract)"
};
dictionary["source_path"] = new ToolProperty
{
Type = "string",
Description = "Source file or directory path (for compress action)"
};
dictionary["dest_path"] = new ToolProperty
{
Type = "string",
Description = "Destination directory (for extract action)"
};
toolParameterSchema.Properties = dictionary;
num = 2;
List<string> list2 = new List<string>(num);
CollectionsMarshal.SetCount(list2, num);
Span<string> span2 = CollectionsMarshal.AsSpan(list2);
span2[0] = "action";
span2[1] = "zip_path";
toolParameterSchema.Required = list2;
return toolParameterSchema;
}
}
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken))
{
string text = args.GetProperty("action").GetString() ?? "";
string text2 = args.GetProperty("zip_path").GetString() ?? "";
if (!Path.IsPathRooted(text2))
{
text2 = Path.Combine(context.WorkFolder, text2);
}
try
{
if (1 == 0)
{
}
ToolResult result = text switch
{
"compress" => Compress(args, text2, context),
"extract" => Extract(args, text2, context),
"list" => ListContents(text2),
_ => ToolResult.Fail("Unknown action: " + text),
};
if (1 == 0)
{
}
return Task.FromResult(result);
}
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 value))
{
return ToolResult.Fail("'source_path' is required for compress action");
}
string text = value.GetString() ?? "";
if (!Path.IsPathRooted(text))
{
text = Path.Combine(context.WorkFolder, text);
}
if (File.Exists(zipPath))
{
return ToolResult.Fail("Zip file already exists: " + zipPath);
}
if (Directory.Exists(text))
{
ZipFile.CreateFromDirectory(text, zipPath, CompressionLevel.Optimal, includeBaseDirectory: false);
FileInfo fileInfo = new FileInfo(zipPath);
return ToolResult.Ok($"✓ Created {zipPath} ({fileInfo.Length / 1024}KB)", zipPath);
}
if (File.Exists(text))
{
using (ZipArchive destination = ZipFile.Open(zipPath, ZipArchiveMode.Create))
{
destination.CreateEntryFromFile(text, Path.GetFileName(text), CompressionLevel.Optimal);
FileInfo fileInfo2 = new FileInfo(zipPath);
return ToolResult.Ok($"✓ Created {zipPath} ({fileInfo2.Length / 1024}KB)", zipPath);
}
}
return ToolResult.Fail("Source not found: " + text);
}
private static ToolResult Extract(JsonElement args, string zipPath, AgentContext context)
{
if (!File.Exists(zipPath))
{
return ToolResult.Fail("Zip file not found: " + zipPath);
}
JsonElement value;
string text = (args.TryGetProperty("dest_path", out value) ? (value.GetString() ?? "") : "");
if (string.IsNullOrEmpty(text))
{
text = Path.Combine(Path.GetDirectoryName(zipPath) ?? context.WorkFolder, Path.GetFileNameWithoutExtension(zipPath));
}
if (!Path.IsPathRooted(text))
{
text = Path.Combine(context.WorkFolder, text);
}
using (ZipArchive zipArchive = ZipFile.OpenRead(zipPath))
{
long num = zipArchive.Entries.Sum((ZipArchiveEntry e) => e.Length);
if (num > 524288000)
{
return ToolResult.Fail($"Uncompressed size ({num / 1024 / 1024}MB) exceeds 500MB limit");
}
foreach (ZipArchiveEntry entry in zipArchive.Entries)
{
string fullPath = Path.GetFullPath(Path.Combine(text, entry.FullName));
if (!fullPath.StartsWith(Path.GetFullPath(text), StringComparison.OrdinalIgnoreCase))
{
return ToolResult.Fail("Security: entry '" + entry.FullName + "' escapes destination directory");
}
}
}
Directory.CreateDirectory(text);
ZipFile.ExtractToDirectory(zipPath, text, overwriteFiles: true);
int value2 = Directory.GetFiles(text, "*", SearchOption.AllDirectories).Length;
return ToolResult.Ok($"✓ Extracted {value2} files to {text}");
}
private static ToolResult ListContents(string zipPath)
{
if (!File.Exists(zipPath))
{
return ToolResult.Fail("Zip file not found: " + zipPath);
}
using ZipArchive zipArchive = ZipFile.OpenRead(zipPath);
StringBuilder stringBuilder = new StringBuilder();
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(14, 2, stringBuilder2);
handler.AppendLiteral("Archive: ");
handler.AppendFormatted(Path.GetFileName(zipPath));
handler.AppendLiteral(" (");
handler.AppendFormatted(new FileInfo(zipPath).Length / 1024);
handler.AppendLiteral("KB)");
stringBuilder3.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("Entries: ");
handler.AppendFormatted(zipArchive.Entries.Count);
stringBuilder4.AppendLine(ref handler);
stringBuilder.AppendLine();
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 3, stringBuilder2);
handler.AppendFormatted<string>("Size", 10);
handler.AppendLiteral(" ");
handler.AppendFormatted<string>("Compressed", 10);
handler.AppendLiteral(" ");
handler.AppendFormatted("Name");
stringBuilder5.AppendLine(ref handler);
stringBuilder.AppendLine(new string('-', 60));
int num = Math.Min(zipArchive.Entries.Count, 200);
foreach (ZipArchiveEntry item in zipArchive.Entries.Take(num))
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(4, 3, stringBuilder2);
handler.AppendFormatted(item.Length, 10);
handler.AppendLiteral(" ");
handler.AppendFormatted(item.CompressedLength, 10);
handler.AppendLiteral(" ");
handler.AppendFormatted(item.FullName);
stringBuilder6.AppendLine(ref handler);
}
if (zipArchive.Entries.Count > num)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(22, 1, stringBuilder2);
handler.AppendLiteral("\n... and ");
handler.AppendFormatted(zipArchive.Entries.Count - num);
handler.AppendLiteral(" more entries");
stringBuilder7.AppendLine(ref handler);
}
long num2 = zipArchive.Entries.Sum((ZipArchiveEntry e) => e.Length);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder8 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(23, 1, stringBuilder2);
handler.AppendLiteral("\nTotal uncompressed: ");
handler.AppendFormatted(num2 / 1024);
handler.AppendLiteral("KB");
stringBuilder8.AppendLine(ref handler);
return ToolResult.Ok(stringBuilder.ToString());
}
}