Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/IAgentTool.cs
lacvet 8cb08576d5 AX Agent 도구·스킬 정합성 재구성 및 실행 품질 보강
변경 목적:
- AX Agent의 도구 이름, 내부 설정, 스킬 정책, 실행 루프 사이의 불일치를 줄이고 전체 동작 품질을 높인다.
- claw-code 수준의 일관된 동작 품질을 참고하되 AX 구조에 맞는 고유한 카탈로그·정규화 레이어로 재구성한다.

핵심 수정사항:
- 도구 canonical id, legacy alias, 탭 노출, 설정 카테고리, read-only 분류를 중앙 카탈로그로 통합했다.
- ToolRegistry, AgentLoopService, 병렬 실행 분류, 권한 처리, 훅 처리, 스킬 allowed-tools 해석이 같은 이름 체계를 사용하도록 정리했다.
- Agent 설정/일반 설정/도움말의 도구 카드와 훅 편집기, 스킬 설명을 현재 런타임 구조에 맞게 갱신했다.
- 컨텍스트 압축, intent gate, spawn agents, session learning, model prompt adapter, workspace context 관련 변경과 테스트 추가를 함께 반영했다.
- 문서 이력과 비교/로드맵 문서를 최신 상태로 갱신했다.

검증 결과:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_toolcat\ -p:IntermediateOutputPath=obj\verify_toolcat\ : 경고 0 / 오류 0
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\verify_toolcat_tests\ -p:IntermediateOutputPath=obj\verify_toolcat_tests\ : 통과 8
2026-04-14 17:52:46 +09:00

557 lines
23 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>
/// 도구가 활성화되는 탭 카테고리.
/// null이면 모든 탭에서 사용 가능.
/// "Cowork" = Cowork 전용, "Code" = Code 전용, "Chat" = Chat 전용.
/// 쉼표 구분으로 복수 탭 지정 가능: "Cowork,Code".
/// </summary>
string? TabCategory => null;
/// <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", "spawn_agents", "test_loop",
};
private static readonly HashSet<string> DangerousAutoTools = new(StringComparer.OrdinalIgnoreCase)
{
"process",
"build_run",
"spawn_agent",
"spawn_agents",
"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_rules", "task_create", "task_update", "task_stop",
"team_create", "team_delete", "cron_create", "cron_delete", "zip_tool",
};
private static readonly HashSet<string> ProcessLikeTools = new(StringComparer.OrdinalIgnoreCase)
{
"process", "build_run", "test_loop", "snippet_runner", "spawn_agent", "spawn_agents", "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.
/// 실행 중 사용자가 UI에서 권한을 바꾸면 SyncContextFromSettings를 통해 업데이트됨.</summary>
public string Permission { get; set; } = "Default";
/// <summary>도구별 권한 오버라이드. 키: 도구명 또는 tool@pattern, 값: 권한 모드.</summary>
public Dictionary<string, string> ToolPermissions { get; set; } = new();
/// <summary>차단 경로 패턴 목록.</summary>
public List<string> BlockedPaths { get; set; } = new();
/// <summary>차단 확장자 목록.</summary>
public List<string> BlockedExtensions { get; set; } = new();
/// <summary>현재 활성 탭. "Chat" | "Cowork" | "Code".</summary>
public string ActiveTab { get; set; } = "Chat";
/// <summary>운영 모드. internal(사내) | external(사외).
/// 실행 중 사용자가 설정에서 모드를 바꾸면 SyncContextFromSettings를 통해 업데이트됨.</summary>
public string OperationMode { get; set; } = AxCopilot.Services.OperationModePolicy.InternalMode;
/// <summary>개발자 모드: 상세 이력 표시.</summary>
public bool DevMode { get; set; }
/// <summary>개발자 모드: 도구 실행 전 매번 사용자 승인 대기.</summary>
public bool DevModeStepApproval { get; set; }
/// <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;
}
// 작업 폴더 제한: 작업 폴더가 설정되어 있으면 하위 경로만 허용
// 사내 모드에서는 외부 경로도 사용자 승인 후 접근 가능 (CheckToolPermissionAsync에서 강제 승인 처리)
if (!string.IsNullOrEmpty(WorkFolder))
{
var workFull = Path.GetFullPath(WorkFolder);
if (!fullPath.StartsWith(workFull, StringComparison.OrdinalIgnoreCase))
{
// 사내 모드: 외부 경로는 IsPathAllowed에서 차단하지 않고 권한 검증 단계로 위임
if (AxCopilot.Services.OperationModePolicy.IsInternal(OperationMode))
return true; // CheckToolPermissionAsync에서 강제 승인 요청됨
return false;
}
}
return true;
}
/// <summary>경로가 워크스페이스 외부인지 확인합니다.</summary>
public bool IsOutsideWorkspace(string path)
{
if (string.IsNullOrEmpty(WorkFolder)) return false;
if (string.IsNullOrWhiteSpace(path)) return false;
// ── 방어: path 형태가 아닌 식별자(도구명 등)가 잘못 전달되면 내부로 간주 ──
// DescribeToolTarget가 primary 없을 때 toolName("html_create")을 반환하는 경로의 fallback.
// 이 경우 Path.GetFullPath는 앱 CWD 기준으로 해석되어 "외부"로 오판될 위험이 있음.
var looksLikePath = path.Contains('/')
|| path.Contains('\\')
|| Path.IsPathRooted(path)
|| Path.HasExtension(path);
if (!looksLikePath) return false;
try
{
var fullPath = Path.GetFullPath(path);
var workFull = Path.GetFullPath(WorkFolder);
return !fullPath.StartsWith(workFull, StringComparison.OrdinalIgnoreCase);
}
catch { 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 = AgentToolCatalog.Canonicalize(toolName);
if (TryResolvePatternPermission(toolName, target, out var patternPermission))
return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(patternPermission));
if (ToolPermissions.TryGetValue(normalizedToolName, out var toolPerm) &&
!string.IsNullOrWhiteSpace(toolPerm))
return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(toolPerm));
if (ToolPermissions.TryGetValue(toolName, out 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(normalizedToolName)
? 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;
// ── 사내 모드 보안 강화: 워크스페이스 외부 경로 접근 시 무조건 사용자 승인 ──
// BypassPermissions / AcceptEdits 모드여도 외부 경로는 강제 승인 필요
if (AxCopilot.Services.OperationModePolicy.IsInternal(OperationMode)
&& !string.IsNullOrWhiteSpace(target)
&& IsOutsideWorkspace(target))
{
if (AskPermission == null) return false;
var extTarget = target.Trim();
var extCacheKey = $"ext_ws|{toolName}|{extTarget}";
lock (_permissionLock)
{
if (_approvedPermissionCache.Contains(extCacheKey))
return true;
}
var extAllowed = await AskPermission(toolName, extTarget);
if (extAllowed)
{
lock (_permissionLock)
_approvedPermissionCache.Add(extCacheKey);
}
return extAllowed;
}
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 = AgentToolCatalog.Canonicalize(toolName);
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)
{
ruleTool = AgentToolCatalog.Canonicalize(trimmed[..at].Trim());
rulePattern = trimmed[(at + 1)..].Trim();
return !string.IsNullOrWhiteSpace(ruleTool) && !string.IsNullOrWhiteSpace(rulePattern);
}
var pipe = trimmed.IndexOf('|');
if (pipe > 0 && pipe < trimmed.Length - 1)
{
ruleTool = AgentToolCatalog.Canonicalize(trimmed[..pipe].Trim());
rulePattern = trimmed[(pipe + 1)..].Trim();
return !string.IsNullOrWhiteSpace(ruleTool) && !string.IsNullOrWhiteSpace(rulePattern);
}
var open = trimmed.IndexOf('(');
if (open > 0 && trimmed.EndsWith(")", StringComparison.Ordinal))
{
ruleTool = AgentToolCatalog.Canonicalize(trimmed[..open].Trim());
rulePattern = trimmed[(open + 1)..^1].Trim();
return !string.IsNullOrWhiteSpace(ruleTool) && !string.IsNullOrWhiteSpace(rulePattern);
}
return false;
}
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(AgentToolCatalog.Canonicalize(toolName));
private static bool IsProcessLikeTool(string toolName) => ProcessLikeTools.Contains(AgentToolCatalog.Canonicalize(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(AgentToolCatalog.Canonicalize(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, // 에이전트 재개
UserMessage, // 실행 중 사용자 메시지 주입
}