변경 목적: - 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
456 lines
18 KiB
C#
456 lines
18 KiB
C#
using System;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
internal enum TranscriptRowKind
|
|
{
|
|
AssistantText,
|
|
Thinking,
|
|
ToolActivity,
|
|
Permission,
|
|
ToolResult,
|
|
CompactBoundary,
|
|
Waiting,
|
|
PlanApproval,
|
|
Status,
|
|
}
|
|
|
|
internal sealed record AgentTranscriptRowPresentation(
|
|
TranscriptRowKind Kind,
|
|
string BadgeLabel,
|
|
string Title,
|
|
string Description,
|
|
string GroupKey,
|
|
bool CanGroup,
|
|
bool Emphasize);
|
|
|
|
internal static class AgentTranscriptDisplayCatalog
|
|
{
|
|
public static string GetDisplayName(string? rawName, bool slashPrefix = false)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(rawName))
|
|
return slashPrefix ? "/skill" : "도구";
|
|
|
|
var normalized = rawName.Trim();
|
|
var lowered = normalized.ToLowerInvariant();
|
|
var mapped = lowered switch
|
|
{
|
|
"file_read" => "파일 읽기",
|
|
"file_write" => "파일 쓰기",
|
|
"file_edit" => "파일 수정",
|
|
"file_watch" => "파일 감시",
|
|
"file_info" => "파일 정보",
|
|
"file_manage" => "파일 관리",
|
|
"glob" => "파일 찾기",
|
|
"grep" => "내용 검색",
|
|
"folder_map" => "폴더 구조",
|
|
"multi_read" => "다중 파일 읽기",
|
|
|
|
"document_read" or "document_reader" => "문서 읽기",
|
|
"document_plan" or "document_planner" => "문서 계획",
|
|
"document_assemble" or "document_assembler" => "문서 조합",
|
|
"document_review" => "문서 검토",
|
|
"format_convert" => "형식 변환",
|
|
"template_render" => "템플릿 렌더",
|
|
"html_create" => "HTML 생성",
|
|
"docx_create" => "Word 생성",
|
|
"markdown_create" or "md_create" => "Markdown 생성",
|
|
"excel_create" or "xlsx_create" => "Excel 생성",
|
|
"csv_create" => "CSV 생성",
|
|
"pptx_create" => "PowerPoint 생성",
|
|
|
|
"build_run" => "빌드/실행",
|
|
"test_loop" => "테스트 루프",
|
|
"dev_env_detect" => "개발 환경 감지",
|
|
"git_tool" => "Git",
|
|
"diff_tool" => "Diff",
|
|
"diff_preview" => "Diff 미리보기",
|
|
|
|
"process" => "명령 실행",
|
|
"bash" => "Bash",
|
|
"powershell" => "PowerShell",
|
|
"web_fetch" => "웹 요청",
|
|
"http" => "HTTP 요청",
|
|
"user_ask" => "질문 요청",
|
|
"suggest_actions" => "다음 작업 제안",
|
|
|
|
"task_create" => "작업 생성",
|
|
"task_update" => "작업 갱신",
|
|
"task_list" => "작업 목록",
|
|
"task_get" => "작업 조회",
|
|
"task_stop" => "작업 중지",
|
|
"task_output" => "작업 출력",
|
|
"spawn_agent" => "서브에이전트",
|
|
"spawn_agents" => "배치 에이전트",
|
|
"wait_agents" => "에이전트 대기",
|
|
_ => normalized.Replace('_', ' ').Trim(),
|
|
};
|
|
|
|
if (!slashPrefix)
|
|
return mapped;
|
|
|
|
if (normalized.StartsWith('/'))
|
|
return normalized;
|
|
|
|
return "/" + lowered.Replace('_', '-').Replace(' ', '-');
|
|
}
|
|
|
|
public static string GetEventBadgeLabel(AgentEvent evt)
|
|
{
|
|
if (evt.Type == AgentEventType.SkillCall)
|
|
return "스킬";
|
|
|
|
if (evt.Type is AgentEventType.PermissionRequest or AgentEventType.PermissionGranted or AgentEventType.PermissionDenied)
|
|
return "권한";
|
|
|
|
return GetToolCategoryLabel(evt.ToolName);
|
|
}
|
|
|
|
public static string GetTaskCategoryLabel(string? kind, string? title)
|
|
{
|
|
if (string.Equals(kind, "permission", StringComparison.OrdinalIgnoreCase))
|
|
return "권한";
|
|
if (string.Equals(kind, "queue", StringComparison.OrdinalIgnoreCase))
|
|
return "대기열";
|
|
if (string.Equals(kind, "hook", StringComparison.OrdinalIgnoreCase))
|
|
return "훅";
|
|
if (string.Equals(kind, "subagent", StringComparison.OrdinalIgnoreCase))
|
|
return "에이전트";
|
|
if (string.Equals(kind, "tool", StringComparison.OrdinalIgnoreCase))
|
|
return GetToolCategoryLabel(title);
|
|
|
|
return "작업";
|
|
}
|
|
|
|
public static string BuildEventSummary(AgentEvent evt, string displayName)
|
|
{
|
|
var summary = (evt.Summary ?? string.Empty).Trim();
|
|
if (!string.IsNullOrWhiteSpace(summary))
|
|
return StripNonBmpCharacters(summary);
|
|
|
|
return evt.Type switch
|
|
{
|
|
AgentEventType.ToolCall => $"{displayName} 실행 준비",
|
|
AgentEventType.ToolResult => evt.Success ? $"{displayName} 실행 완료" : $"{displayName} 실행 실패",
|
|
AgentEventType.SkillCall => $"{displayName} 실행",
|
|
AgentEventType.PermissionRequest => $"{displayName} 실행 전에 권한 확인이 필요합니다.",
|
|
AgentEventType.PermissionGranted => $"{displayName} 실행 권한을 확인했습니다.",
|
|
AgentEventType.PermissionDenied => $"{displayName} 실행 권한이 거부되었습니다.",
|
|
AgentEventType.Complete => "에이전트 작업이 완료되었습니다.",
|
|
AgentEventType.Error => "에이전트 실행 중 오류가 발생했습니다.",
|
|
_ => summary,
|
|
};
|
|
}
|
|
|
|
public static AgentTranscriptRowPresentation ResolveRowPresentation(AgentEvent evt, string itemDisplayName, string transcriptBadgeLabel)
|
|
{
|
|
var summary = (evt.Summary ?? string.Empty).Trim();
|
|
var toolName = (evt.ToolName ?? string.Empty).Trim().ToLowerInvariant();
|
|
var resultPresentation = evt.Type == AgentEventType.ToolResult
|
|
? ToolResultPresentationCatalog.Resolve(evt, transcriptBadgeLabel)
|
|
: null;
|
|
var permissionPresentation = evt.Type switch
|
|
{
|
|
AgentEventType.PermissionRequest => PermissionRequestPresentationCatalog.Resolve(evt.ToolName, pending: true),
|
|
AgentEventType.PermissionGranted => PermissionRequestPresentationCatalog.Resolve(evt.ToolName, pending: false),
|
|
_ => null
|
|
};
|
|
|
|
if (string.Equals(toolName, "agent_wait", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return new AgentTranscriptRowPresentation(
|
|
TranscriptRowKind.Waiting,
|
|
"대기",
|
|
"처리 중...",
|
|
string.IsNullOrWhiteSpace(summary) ? "작업을 계속 진행하기 위한 응답을 기다리는 중입니다." : summary,
|
|
"waiting:agent",
|
|
true,
|
|
true);
|
|
}
|
|
|
|
if (string.Equals(toolName, "context_compaction", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return new AgentTranscriptRowPresentation(
|
|
TranscriptRowKind.CompactBoundary,
|
|
"압축",
|
|
"컨텍스트 압축 중...",
|
|
string.IsNullOrWhiteSpace(summary) ? "긴 대화를 계속 진행하기 위해 컨텍스트를 정리하고 있습니다." : summary,
|
|
"compact:context",
|
|
true,
|
|
true);
|
|
}
|
|
|
|
if (evt.Type == AgentEventType.Planning)
|
|
{
|
|
var title = evt.Steps is { Count: > 0 }
|
|
? $"계획 {evt.Steps.Count}단계 정리"
|
|
: "작업 계획 정리 중";
|
|
return new AgentTranscriptRowPresentation(
|
|
TranscriptRowKind.Thinking,
|
|
"계획",
|
|
title,
|
|
string.IsNullOrWhiteSpace(summary) ? "실행 순서와 필요한 도구를 정리하고 있습니다." : summary,
|
|
"planning",
|
|
true,
|
|
false);
|
|
}
|
|
|
|
if (evt.Type == AgentEventType.Decision)
|
|
{
|
|
return new AgentTranscriptRowPresentation(
|
|
TranscriptRowKind.PlanApproval,
|
|
"확인",
|
|
"계획 확인 필요",
|
|
string.IsNullOrWhiteSpace(summary) ? "사용자 확인이 필요한 계획 단계입니다." : summary,
|
|
"plan:approval",
|
|
false,
|
|
true);
|
|
}
|
|
|
|
if (evt.Type is AgentEventType.StepStart or AgentEventType.StepDone)
|
|
{
|
|
var title = evt.StepTotal > 0
|
|
? evt.Type == AgentEventType.StepStart
|
|
? $"{evt.StepCurrent}/{evt.StepTotal} 단계 진행"
|
|
: $"{evt.StepCurrent}/{evt.StepTotal} 단계 완료"
|
|
: evt.Type == AgentEventType.StepStart ? "단계 진행" : "단계 완료";
|
|
return new AgentTranscriptRowPresentation(
|
|
TranscriptRowKind.ToolActivity,
|
|
"단계",
|
|
title,
|
|
string.IsNullOrWhiteSpace(summary) ? title : summary,
|
|
$"step:{evt.StepCurrent}:{evt.StepTotal}:{evt.Type}",
|
|
false,
|
|
false);
|
|
}
|
|
|
|
if (evt.Type == AgentEventType.Thinking)
|
|
{
|
|
var title = ResolveThinkingTitle(summary, toolName);
|
|
return new AgentTranscriptRowPresentation(
|
|
TranscriptRowKind.Thinking,
|
|
"생각",
|
|
title,
|
|
string.IsNullOrWhiteSpace(summary) ? title : summary,
|
|
$"thinking:{ResolveActivityGroup(toolName, summary)}",
|
|
true,
|
|
false);
|
|
}
|
|
|
|
if (evt.Type == AgentEventType.ToolCall || evt.Type == AgentEventType.SkillCall)
|
|
{
|
|
var group = ResolveActivityGroup(toolName, summary);
|
|
var title = BuildActivityTitle(toolName, itemDisplayName, summary);
|
|
var badge = evt.Type == AgentEventType.SkillCall ? "스킬" : "도구";
|
|
return new AgentTranscriptRowPresentation(
|
|
TranscriptRowKind.ToolActivity,
|
|
badge,
|
|
title,
|
|
BuildActivityDescription(group, summary),
|
|
$"activity:{group}",
|
|
true,
|
|
false);
|
|
}
|
|
|
|
if (permissionPresentation != null)
|
|
{
|
|
return new AgentTranscriptRowPresentation(
|
|
TranscriptRowKind.Permission,
|
|
"권한",
|
|
permissionPresentation.Label,
|
|
permissionPresentation.Description,
|
|
$"permission:{permissionPresentation.Kind}",
|
|
false,
|
|
true);
|
|
}
|
|
|
|
if (resultPresentation != null)
|
|
{
|
|
// ToolResult의 GroupKey를 선행 ToolCall과 동일한 activity 그룹으로 설정
|
|
// → ProcessFeed에서 ToolCall 카드를 ToolResult로 교체(머지)
|
|
var resultGroup = ResolveActivityGroup(toolName, summary);
|
|
return new AgentTranscriptRowPresentation(
|
|
TranscriptRowKind.ToolResult,
|
|
"결과",
|
|
resultPresentation.Label,
|
|
resultPresentation.Description,
|
|
$"activity:{resultGroup}",
|
|
true,
|
|
resultPresentation.NeedsAttention);
|
|
}
|
|
|
|
if (evt.Type == AgentEventType.Error)
|
|
{
|
|
return new AgentTranscriptRowPresentation(
|
|
TranscriptRowKind.Status,
|
|
"오류",
|
|
"실행 중 오류 발생",
|
|
string.IsNullOrWhiteSpace(summary) ? "에이전트 실행 중 오류가 발생했습니다." : summary,
|
|
"status:error",
|
|
false,
|
|
true);
|
|
}
|
|
|
|
if (evt.Type == AgentEventType.Complete)
|
|
{
|
|
return new AgentTranscriptRowPresentation(
|
|
TranscriptRowKind.Status,
|
|
"완료",
|
|
"작업 완료",
|
|
string.IsNullOrWhiteSpace(summary) ? "에이전트 작업이 완료되었습니다." : summary,
|
|
"status:complete",
|
|
false,
|
|
false);
|
|
}
|
|
|
|
return new AgentTranscriptRowPresentation(
|
|
TranscriptRowKind.Status,
|
|
transcriptBadgeLabel,
|
|
string.IsNullOrWhiteSpace(summary) ? transcriptBadgeLabel : summary,
|
|
string.IsNullOrWhiteSpace(summary) ? transcriptBadgeLabel : summary,
|
|
$"status:{evt.Type}",
|
|
false,
|
|
false);
|
|
}
|
|
|
|
private static string ResolveThinkingTitle(string summary, string toolName)
|
|
{
|
|
if (summary.Contains("검증", StringComparison.OrdinalIgnoreCase))
|
|
return "결과 검증 중...";
|
|
if (summary.Contains("diff", StringComparison.OrdinalIgnoreCase))
|
|
return "변경 내용 확인 중...";
|
|
if (summary.Contains("retry", StringComparison.OrdinalIgnoreCase) || summary.Contains("재시도", StringComparison.OrdinalIgnoreCase))
|
|
return "재시도 준비 중...";
|
|
if (summary.Contains("fallback", StringComparison.OrdinalIgnoreCase) || summary.Contains("자동 생성", StringComparison.OrdinalIgnoreCase))
|
|
return "대체 경로 준비 중...";
|
|
if (toolName.Contains("document"))
|
|
return "문서 흐름 정리 중...";
|
|
return string.IsNullOrWhiteSpace(summary) ? "분석 중..." : summary;
|
|
}
|
|
|
|
private static string ResolveActivityGroup(string toolName, string summary)
|
|
{
|
|
if (toolName is "file_read" or "document_read" or "glob" or "grep" or "folder_map" or "multi_read")
|
|
return "read";
|
|
if (toolName is "file_edit" or "file_write")
|
|
return "edit";
|
|
if (toolName.Contains("build") || toolName.Contains("test") || toolName == "process")
|
|
return "execute";
|
|
if (toolName.Contains("git") || toolName.Contains("diff"))
|
|
return "git";
|
|
if (toolName.Contains("document") || toolName.Contains("html_create") || toolName.Contains("docx_create") || toolName.Contains("markdown_create"))
|
|
return "document";
|
|
if (toolName.Contains("web") || toolName.Contains("http"))
|
|
return "web";
|
|
if (summary.Contains("권한", StringComparison.OrdinalIgnoreCase))
|
|
return "permission";
|
|
return "general";
|
|
}
|
|
|
|
private static string BuildActivityTitle(string toolName, string itemDisplayName, string summary)
|
|
{
|
|
if (toolName is "multi_read")
|
|
return "여러 파일 읽는 중";
|
|
if (toolName is "file_read" or "document_read")
|
|
return "파일 읽는 중";
|
|
if (toolName is "glob")
|
|
return "관련 파일 찾는 중";
|
|
if (toolName is "grep")
|
|
return "코드 검색 중";
|
|
if (toolName is "folder_map")
|
|
return "구조 확인 중";
|
|
if (toolName is "file_edit")
|
|
return "파일 수정 중";
|
|
if (toolName is "file_write")
|
|
return "파일 작성 중";
|
|
if (toolName.Contains("build") || toolName.Contains("test"))
|
|
return "빌드/테스트 실행 중";
|
|
if (toolName == "process" || toolName == "bash" || toolName == "powershell")
|
|
return "명령 실행 중";
|
|
if (toolName.Contains("git") || toolName.Contains("diff"))
|
|
return "Git 작업 중";
|
|
if (toolName.Contains("document") || toolName.Contains("html_create") || toolName.Contains("docx_create"))
|
|
return "문서 결과 만드는 중";
|
|
if (toolName.Contains("web") || toolName.Contains("http"))
|
|
return "웹 정보 확인 중";
|
|
|
|
if (!string.IsNullOrWhiteSpace(itemDisplayName))
|
|
return $"{itemDisplayName} 실행 중";
|
|
return string.IsNullOrWhiteSpace(summary) ? "도구 실행 중" : summary;
|
|
}
|
|
|
|
private static string BuildActivityDescription(string group, string summary)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(summary))
|
|
return summary;
|
|
|
|
return group switch
|
|
{
|
|
"read" => "질문과 관련된 파일과 내용만 추려서 확인하고 있습니다.",
|
|
"edit" => "필요한 변경만 적용하고 결과를 다시 확인하고 있습니다.",
|
|
"execute" => "실행 결과와 로그를 확인해 다음 단계를 판단하고 있습니다.",
|
|
"git" => "변경 범위와 저장소 상태를 확인하고 있습니다.",
|
|
"document" => "문서 산출물을 준비하고 있습니다.",
|
|
"web" => "필요한 외부 정보를 확인하고 있습니다.",
|
|
_ => "다음 단계를 진행하기 위한 작업을 실행하고 있습니다."
|
|
};
|
|
}
|
|
|
|
private static string GetToolCategoryLabel(string? rawName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(rawName))
|
|
return "도구";
|
|
|
|
return rawName.Trim().ToLowerInvariant() switch
|
|
{
|
|
"file_read" or "file_write" or "file_edit" or "glob" or "grep" or "folder_map" or "file_watch" or "file_info" or "file_manage" or "multi_read"
|
|
=> "파일",
|
|
"build_run" or "test_loop" or "dev_env_detect"
|
|
=> "빌드",
|
|
"git_tool" or "diff_tool" or "diff_preview"
|
|
=> "Git",
|
|
"document_read" or "document_reader" or "document_plan" or "document_planner" or "document_assemble" or "document_assembler" or "document_review" or "format_convert" or "template_render" or "html_create" or "docx_create"
|
|
=> "문서",
|
|
"user_ask"
|
|
=> "질문",
|
|
"suggest_actions"
|
|
=> "제안",
|
|
"process" or "bash" or "powershell"
|
|
=> "명령",
|
|
"spawn_agent" or "spawn_agents" or "wait_agents"
|
|
=> "에이전트",
|
|
"web_fetch" or "http"
|
|
=> "웹",
|
|
_ => "도구",
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// WPF 기본 폰트(Segoe UI)에서 렌더링되지 않는 비-BMP 유니코드 문자(이모지 등)를 제거합니다.
|
|
/// LLM 응답에 이모지가 포함되면 깨져서 표시되는 문제를 방지합니다.
|
|
/// </summary>
|
|
public static string StripNonBmpCharacters(string text)
|
|
{
|
|
if (string.IsNullOrEmpty(text)) return string.Empty;
|
|
|
|
// Consolas / Segoe UI에서 렌더링 불가한 비-BMP 유니코드(이모지, 서로게이트 쌍) 제거
|
|
var sb = new System.Text.StringBuilder(text.Length);
|
|
for (int i = 0; i < text.Length; i++)
|
|
{
|
|
var c = text[i];
|
|
if (char.IsHighSurrogate(c))
|
|
{
|
|
if (i + 1 < text.Length && char.IsLowSurrogate(text[i + 1]))
|
|
i++;
|
|
continue;
|
|
}
|
|
if (char.IsLowSurrogate(c)) continue;
|
|
if (c >= 0x2600 && c <= 0x27BF) continue; // Misc symbols, Dingbats
|
|
if (c >= 0x2B50 && c <= 0x2B55) continue; // Additional symbols
|
|
if (c >= 0xFE00 && c <= 0xFE0F) continue; // Variation selectors
|
|
sb.Append(c);
|
|
}
|
|
return sb.ToString().Trim();
|
|
}
|
|
}
|