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