변경 목적: - 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
394 lines
19 KiB
C#
394 lines
19 KiB
C#
using System.IO;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
/// <summary>
|
|
/// Markdown (.md) 문서를 생성하는 내장 스킬.
|
|
/// sections 배열로 구조화된 콘텐츠 블록(heading/paragraph/table/list/callout/code/quote/divider/toc)을 지원합니다.
|
|
/// frontmatter, toc 옵션으로 메타데이터와 목차를 자동 생성할 수 있습니다.
|
|
/// content 파라미터로 기존 방식의 원시 마크다운도 계속 지원합니다.
|
|
/// </summary>
|
|
public class MarkdownSkill : IAgentTool
|
|
{
|
|
public string Name => "markdown_create";
|
|
public string Description =>
|
|
"Create a Markdown (.md) document. " +
|
|
"REQUIRED: 'content' (raw markdown string). " +
|
|
"Alternative: set content=\"\" and provide 'sections' array for structured blocks. " +
|
|
"NEVER call this tool with only title — you MUST include content (or sections). " +
|
|
"Use 'sections' for structured content (heading/paragraph/table/list/callout/code/quote/divider/toc). " +
|
|
"Use 'frontmatter' for YAML metadata. Use 'toc' to auto-generate a table of contents.";
|
|
|
|
public ToolParameterSchema Parameters => new()
|
|
{
|
|
Properties = new()
|
|
{
|
|
["path"] = new() { Type = "string", Description = "출력 파일 경로 (.md). 작업 폴더 기준 상대 경로." },
|
|
["title"] = new() { Type = "string", Description = "문서 제목. 제공 시 최상단에 '# 제목' 헤딩을 추가합니다." },
|
|
["content"] = new() { Type = "string", Description = "[REQUIRED unless 'sections' is provided] 원시 마크다운 내용. 문서의 주요 내용을 여기에 작성하세요. " +
|
|
"sections 배열을 사용하려면 content=\"\"로 두고 sections에 최소 1개 이상의 블록을 제공하세요." },
|
|
["sections"] = new()
|
|
{
|
|
Type = "array",
|
|
Description =
|
|
"구조화된 콘텐츠 블록 배열. 각 객체는 'type' 필드를 가집니다. " +
|
|
"타입: " +
|
|
"'heading' {level:1-6, text} — 제목, " +
|
|
"'paragraph' {text} — 단락, " +
|
|
"'table' {headers:[], rows:[[]]} — 표, " +
|
|
"'list' {items:[], ordered:false} — 목록, " +
|
|
"'callout' {style:'info|warning|tip|danger', text} — 콜아웃 블록, " +
|
|
"'code' {language:'python', code:'...'} — 코드 블록, " +
|
|
"'quote' {text, author} — 인용구, " +
|
|
"'divider' — 구분선, " +
|
|
"'toc' — 이 위치에 목차 삽입."
|
|
},
|
|
["frontmatter"] = new()
|
|
{
|
|
Type = "object",
|
|
Description = "YAML 프론트매터 메타데이터 키-값 쌍. 예: {\"author\": \"홍길동\", \"date\": \"2026-04-07\"}."
|
|
},
|
|
["toc"] = new() { Type = "boolean", Description = "true이면 문서 상단(제목 다음)에 목차를 자동 생성합니다." },
|
|
["encoding"] = new() { Type = "string", Description = "파일 인코딩: 'utf-8' (기본값) 또는 'euc-kr'." },
|
|
},
|
|
Required = ["content"]
|
|
};
|
|
|
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
|
{
|
|
// ── 필수 파라미터 ──────────────────────────────────────────────────
|
|
var hasSections = args.SafeTryGetProperty("sections", out var sectionsEl)
|
|
&& sectionsEl.ValueKind == JsonValueKind.Array
|
|
&& sectionsEl.GetArrayLength() > 0;
|
|
var hasContent = args.SafeTryGetProperty("content", out var contentEl)
|
|
&& contentEl.ValueKind == JsonValueKind.String
|
|
&& !string.IsNullOrWhiteSpace(contentEl.SafeGetString());
|
|
var hasFrontmatter= args.SafeTryGetProperty("frontmatter",out var frontEl) && frontEl.ValueKind == JsonValueKind.Object;
|
|
|
|
if (!hasSections && !hasContent)
|
|
{
|
|
return ToolResult.Fail(
|
|
"필수 파라미터 누락: 'content' (마크다운 문자열) 또는 'sections' (배열) 중 하나는 반드시 제공해야 합니다.\n" +
|
|
"다시 호출할 때는 title 외에 반드시 content를 포함하세요. 예:\n" +
|
|
"{\"name\":\"markdown_create\",\"arguments\":{\"title\":\"...\",\"content\":\"## 개요\\n...\\n\\n## 상세\\n...\"}}\n" +
|
|
"sections 배열을 사용하려면 content=\"\"로 두고 sections에 최소 1개 이상의 블록을 제공하세요.");
|
|
}
|
|
|
|
// path 미제공 시 title에서 자동 생성
|
|
string path;
|
|
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
|
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
|
|
{
|
|
path = pathEl.SafeGetString()!;
|
|
}
|
|
else
|
|
{
|
|
var baseTitle = (args.SafeTryGetProperty("title", out var autoT) ? autoT.SafeGetString() : null) ?? "document";
|
|
var safe = System.Text.RegularExpressions.Regex.Replace(baseTitle, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
|
|
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
|
path = (string.IsNullOrWhiteSpace(safe) ? "document" : safe) + ".md";
|
|
}
|
|
var title = args.SafeTryGetProperty("title", out var titleEl) ? titleEl.SafeGetString() : null;
|
|
var useToc = args.SafeTryGetProperty("toc", out var tocEl) && tocEl.ValueKind == JsonValueKind.True;
|
|
var encodingName = args.SafeTryGetProperty("encoding", out var encEl) ? encEl.SafeGetString() ?? "utf-8" : "utf-8";
|
|
|
|
// ── 경로 처리 ──────────────────────────────────────────────────────
|
|
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
|
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
|
if (!fullPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
|
fullPath += ".md";
|
|
|
|
if (!context.IsPathAllowed(fullPath))
|
|
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
|
|
|
|
if (!await context.CheckWritePermissionAsync(Name, fullPath))
|
|
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
|
|
|
|
try
|
|
{
|
|
Encoding fileEncoding;
|
|
try { fileEncoding = Encoding.GetEncoding(encodingName); }
|
|
catch { fileEncoding = new UTF8Encoding(false); }
|
|
|
|
var sb = new StringBuilder();
|
|
|
|
// ── YAML 프론트매터 ────────────────────────────────────────────
|
|
if (hasFrontmatter)
|
|
{
|
|
sb.AppendLine("---");
|
|
foreach (var prop in frontEl.EnumerateObject())
|
|
{
|
|
var val = prop.Value.ValueKind == JsonValueKind.String
|
|
? prop.Value.SafeGetString() ?? ""
|
|
: prop.Value.ToString();
|
|
// 값에 특수 문자가 있으면 인용
|
|
if (val.Contains(':') || val.Contains('#') || val.StartsWith('"'))
|
|
val = $"\"{val.Replace("\"", "\\\"")}\"";
|
|
sb.AppendLine($"{prop.Name}: {val}");
|
|
}
|
|
sb.AppendLine("---");
|
|
sb.AppendLine();
|
|
}
|
|
|
|
// ── 제목 ───────────────────────────────────────────────────────
|
|
if (!string.IsNullOrEmpty(title))
|
|
{
|
|
sb.AppendLine($"# {title}");
|
|
sb.AppendLine();
|
|
}
|
|
|
|
if (hasSections)
|
|
{
|
|
// ── sections 모드 ──────────────────────────────────────────
|
|
// 먼저 heading 목록 수집 (TOC 생성용)
|
|
var headings = CollectHeadings(sectionsEl);
|
|
|
|
// 문서 상단 TOC (toc:true 옵션)
|
|
if (useToc && headings.Count > 0)
|
|
{
|
|
RenderToc(sb, headings);
|
|
sb.AppendLine();
|
|
}
|
|
|
|
// 섹션 렌더링
|
|
foreach (var section in sectionsEl.EnumerateArray())
|
|
{
|
|
if (!section.SafeTryGetProperty("type", out var typeEl)) continue;
|
|
var type = typeEl.SafeGetString()?.ToLowerInvariant() ?? "";
|
|
|
|
switch (type)
|
|
{
|
|
case "heading":
|
|
RenderHeading(sb, section);
|
|
break;
|
|
case "paragraph":
|
|
RenderParagraph(sb, section);
|
|
break;
|
|
case "table":
|
|
RenderTable(sb, section);
|
|
break;
|
|
case "list":
|
|
RenderList(sb, section);
|
|
break;
|
|
case "callout":
|
|
RenderCallout(sb, section);
|
|
break;
|
|
case "code":
|
|
RenderCode(sb, section);
|
|
break;
|
|
case "quote":
|
|
RenderQuote(sb, section);
|
|
break;
|
|
case "divider":
|
|
sb.AppendLine("---");
|
|
sb.AppendLine();
|
|
break;
|
|
case "toc":
|
|
// 인라인 TOC 삽입
|
|
if (headings.Count > 0) RenderToc(sb, headings);
|
|
sb.AppendLine();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// ── 하위 호환: 원시 content 모드 ──────────────────────────
|
|
if (useToc)
|
|
{
|
|
// content에서 헤딩 파싱하여 TOC 생성
|
|
var raw = contentEl.SafeGetString() ?? "";
|
|
var headings = ParseHeadingsFromContent(raw);
|
|
if (headings.Count > 0)
|
|
{
|
|
RenderToc(sb, headings);
|
|
sb.AppendLine();
|
|
}
|
|
}
|
|
sb.Append(contentEl.SafeGetString() ?? "");
|
|
}
|
|
|
|
// ── 파일 쓰기 ──────────────────────────────────────────────────
|
|
var dir = Path.GetDirectoryName(fullPath);
|
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
|
|
|
await File.WriteAllTextAsync(fullPath, sb.ToString(), fileEncoding, ct);
|
|
|
|
var lines = sb.ToString().Split('\n').Length;
|
|
return ToolResult.Ok(
|
|
$"Markdown 문서 저장 완료: {fullPath} ({lines}줄, 인코딩: {encodingName})",
|
|
fullPath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return ToolResult.Fail($"Markdown 문서 저장 중 오류가 발생했습니다: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// ── 헤딩 수집 (sections 배열에서) ────────────────────────────────────────
|
|
private static List<(int Level, string Text)> CollectHeadings(JsonElement sections)
|
|
{
|
|
var list = new List<(int, string)>();
|
|
foreach (var s in sections.EnumerateArray())
|
|
{
|
|
if (!s.SafeTryGetProperty("type", out var t) || t.SafeGetString() != "heading") continue;
|
|
var level = s.SafeTryGetProperty("level", out var lEl) ? lEl.GetInt32() : 2;
|
|
var text = s.SafeTryGetProperty("text", out var xEl) ? xEl.SafeGetString() ?? "" : "";
|
|
if (!string.IsNullOrEmpty(text))
|
|
list.Add((Math.Clamp(level, 1, 6), text));
|
|
}
|
|
return list;
|
|
}
|
|
|
|
// ── 헤딩 파싱 (원시 마크다운에서) ────────────────────────────────────────
|
|
private static List<(int Level, string Text)> ParseHeadingsFromContent(string content)
|
|
{
|
|
var list = new List<(int, string)>();
|
|
foreach (var line in content.Split('\n'))
|
|
{
|
|
var m = Regex.Match(line, @"^(#{1,6})\s+(.+)");
|
|
if (m.Success)
|
|
list.Add((m.Groups[1].Length, m.Groups[2].Value.Trim()));
|
|
}
|
|
return list;
|
|
}
|
|
|
|
// ── TOC 렌더링 ────────────────────────────────────────────────────────────
|
|
private static void RenderToc(StringBuilder sb, List<(int Level, string Text)> headings)
|
|
{
|
|
sb.AppendLine("## 목차");
|
|
sb.AppendLine();
|
|
foreach (var (level, text) in headings)
|
|
{
|
|
// level 1은 들여쓰기 없음, level 2 → 2스페이스, 이후 2씩 추가
|
|
var indent = level <= 1 ? "" : new string(' ', (level - 1) * 2);
|
|
var anchor = HeadingToAnchor(text);
|
|
sb.AppendLine($"{indent}- [{text}](#{anchor})");
|
|
}
|
|
sb.AppendLine();
|
|
}
|
|
|
|
// ── GitHub-style 앵커 생성 ────────────────────────────────────────────────
|
|
private static string HeadingToAnchor(string text)
|
|
{
|
|
// 소문자 변환, 영문/숫자/한글 이외 공백 → '-', 연속 '-' 정리
|
|
var anchor = text.ToLowerInvariant();
|
|
anchor = Regex.Replace(anchor, @"[^\w\uAC00-\uD7A3\s-]", "");
|
|
anchor = Regex.Replace(anchor, @"\s+", "-");
|
|
anchor = Regex.Replace(anchor, @"-+", "-").Trim('-');
|
|
return anchor;
|
|
}
|
|
|
|
// ── 개별 섹션 렌더러 ──────────────────────────────────────────────────────
|
|
|
|
private static void RenderHeading(StringBuilder sb, JsonElement s)
|
|
{
|
|
var level = s.SafeTryGetProperty("level", out var lEl) ? Math.Clamp(lEl.GetInt32(), 1, 6) : 2;
|
|
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
|
|
sb.AppendLine($"{new string('#', level)} {text}");
|
|
sb.AppendLine();
|
|
}
|
|
|
|
private static void RenderParagraph(StringBuilder sb, JsonElement s)
|
|
{
|
|
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
|
|
if (!string.IsNullOrWhiteSpace(text))
|
|
{
|
|
sb.AppendLine(text);
|
|
sb.AppendLine();
|
|
}
|
|
}
|
|
|
|
private static void RenderTable(StringBuilder sb, JsonElement s)
|
|
{
|
|
if (!s.SafeTryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
|
|
return;
|
|
if (!s.SafeTryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
|
|
return;
|
|
|
|
var headers = headersEl.EnumerateArray().Select(h => EscapeTableCell(h.SafeGetString() ?? "")).ToList();
|
|
if (headers.Count == 0) return;
|
|
|
|
// 헤더 행
|
|
sb.AppendLine("| " + string.Join(" | ", headers) + " |");
|
|
// 구분 행
|
|
sb.AppendLine("| " + string.Join(" | ", headers.Select(_ => "---")) + " |");
|
|
// 데이터 행
|
|
foreach (var row in rowsEl.EnumerateArray())
|
|
{
|
|
if (row.ValueKind != JsonValueKind.Array) continue;
|
|
var cells = row.EnumerateArray()
|
|
.Select(c => EscapeTableCell(c.ValueKind == JsonValueKind.Null ? "" : c.ToString()))
|
|
.ToList();
|
|
// 열 수 맞추기
|
|
while (cells.Count < headers.Count) cells.Add("");
|
|
sb.AppendLine("| " + string.Join(" | ", cells.Take(headers.Count)) + " |");
|
|
}
|
|
sb.AppendLine();
|
|
}
|
|
|
|
private static string EscapeTableCell(string value)
|
|
=> value.Replace("|", "\\|").Replace("\n", " ").Replace("\r", "");
|
|
|
|
private static void RenderList(StringBuilder sb, JsonElement s)
|
|
{
|
|
if (!s.SafeTryGetProperty("items", out var itemsEl) || itemsEl.ValueKind != JsonValueKind.Array)
|
|
return;
|
|
|
|
var ordered = s.SafeTryGetProperty("ordered", out var ordEl) && ordEl.ValueKind == JsonValueKind.True;
|
|
int idx = 1;
|
|
foreach (var item in itemsEl.EnumerateArray())
|
|
{
|
|
var text = item.ValueKind == JsonValueKind.String ? item.SafeGetString() ?? "" : item.ToString();
|
|
// 들여쓰기 보존 (앞에 공백/탭이 있으면 그대로)
|
|
var prefix = ordered ? $"{idx++}. " : "- ";
|
|
if (text.StartsWith(" ") || text.StartsWith("\t"))
|
|
sb.AppendLine(text); // 하위 항목: 이미 들여쓰기 포함
|
|
else
|
|
sb.AppendLine($"{prefix}{text}");
|
|
}
|
|
sb.AppendLine();
|
|
}
|
|
|
|
private static void RenderCallout(StringBuilder sb, JsonElement s)
|
|
{
|
|
var style = s.SafeTryGetProperty("style", out var stEl) ? stEl.SafeGetString()?.ToUpperInvariant() ?? "INFO" : "INFO";
|
|
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
|
|
|
|
// 표준 GitHub/Obsidian 콜아웃 형식
|
|
sb.AppendLine($"> [!{style}]");
|
|
foreach (var line in text.Split('\n'))
|
|
sb.AppendLine($"> {line}");
|
|
sb.AppendLine();
|
|
}
|
|
|
|
private static void RenderCode(StringBuilder sb, JsonElement s)
|
|
{
|
|
var lang = s.SafeTryGetProperty("language", out var lEl) ? lEl.SafeGetString() ?? "" : "";
|
|
var code = s.SafeTryGetProperty("code", out var cEl) ? cEl.SafeGetString() ?? "" : "";
|
|
|
|
sb.AppendLine($"```{lang}");
|
|
sb.AppendLine(code);
|
|
sb.AppendLine("```");
|
|
sb.AppendLine();
|
|
}
|
|
|
|
private static void RenderQuote(StringBuilder sb, JsonElement s)
|
|
{
|
|
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
|
|
var author = s.SafeTryGetProperty("author", out var aEl) ? aEl.SafeGetString() : null;
|
|
|
|
foreach (var line in text.Split('\n'))
|
|
sb.AppendLine($"> {line}");
|
|
|
|
if (!string.IsNullOrEmpty(author))
|
|
sb.AppendLine($">");
|
|
sb.AppendLine($"> — {author}");
|
|
|
|
sb.AppendLine();
|
|
}
|
|
}
|