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