Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/IAgentTool.cs

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, // 에이전트 재개
}