using System.IO; using System.Text; using System.Text.Json; namespace AxCopilot.Services.Agent; /// /// 성공적인 실행 흐름을 재사용 가능한 플레이북으로 저장/관리하는 도구. /// .ax/playbooks/ 에 JSON 파일로 저장됩니다. /// public class PlaybookTool : IAgentTool { public string Name => "playbook"; public string Description => "Save, list, describe, or delete execution playbooks. " + "A playbook captures a successful task workflow for reuse.\n" + "- action=\"save\": Save a new playbook (name, description, steps required)\n" + "- action=\"list\": List all saved playbooks\n" + "- action=\"describe\": Show full playbook details (id required)\n" + "- action=\"delete\": Delete a playbook (id required)"; public ToolParameterSchema Parameters => new() { Properties = new() { ["action"] = new() { Type = "string", Description = "save | list | describe | delete", Enum = ["save", "list", "describe", "delete"], }, ["name"] = new() { Type = "string", Description = "Playbook name (for save)", }, ["description"] = new() { Type = "string", Description = "What this playbook does (for save)", }, ["steps"] = new() { Type = "array", Description = "List of step descriptions (for save)", Items = new() { Type = "string", Description = "Step description" }, }, ["tools_used"] = new() { Type = "array", Description = "List of tool names used in this workflow (for save)", Items = new() { Type = "string", Description = "Tool name" }, }, ["id"] = new() { Type = "integer", Description = "Playbook ID (for describe/delete)", }, }, Required = ["action"], }; public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default) { if (!args.TryGetProperty("action", out var actionEl)) return ToolResult.Fail("action이 필요합니다."); var action = actionEl.GetString() ?? ""; if (string.IsNullOrEmpty(context.WorkFolder)) return ToolResult.Fail("작업 폴더가 설정되지 않았습니다."); var playbookDir = Path.Combine(context.WorkFolder, ".ax", "playbooks"); return action switch { "save" => await SavePlaybook(args, playbookDir, ct), "list" => ListPlaybooks(playbookDir), "describe" => await DescribePlaybook(args, playbookDir, ct), "delete" => DeletePlaybook(args, playbookDir), _ => ToolResult.Fail($"알 수 없는 액션: {action}. save | list | describe | delete 중 선택하세요."), }; } private static async Task SavePlaybook(JsonElement args, string playbookDir, CancellationToken ct) { var name = args.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; var description = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : ""; if (string.IsNullOrWhiteSpace(name)) return ToolResult.Fail("플레이북 name이 필요합니다."); if (string.IsNullOrWhiteSpace(description)) return ToolResult.Fail("플레이북 description이 필요합니다."); // steps 파싱 var steps = new List(); if (args.TryGetProperty("steps", out var stepsEl) && stepsEl.ValueKind == JsonValueKind.Array) { foreach (var step in stepsEl.EnumerateArray()) { var s = step.GetString(); if (!string.IsNullOrWhiteSpace(s)) steps.Add(s); } } if (steps.Count == 0) return ToolResult.Fail("최소 1개 이상의 step이 필요합니다."); // tools_used 파싱 var toolsUsed = new List(); if (args.TryGetProperty("tools_used", out var toolsEl) && toolsEl.ValueKind == JsonValueKind.Array) { foreach (var tool in toolsEl.EnumerateArray()) { var t = tool.GetString(); if (!string.IsNullOrWhiteSpace(t)) toolsUsed.Add(t); } } try { Directory.CreateDirectory(playbookDir); // 다음 ID 결정 var nextId = GetNextId(playbookDir); // 파일명에서 비안전 문자 제거 var safeName = string.Join("_", name.Split(Path.GetInvalidFileNameChars())); var fileName = $"{nextId}_{safeName}.json"; var playbook = new PlaybookData { Id = nextId, Name = name, Description = description, Steps = steps, ToolsUsed = toolsUsed, CreatedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), }; var json = JsonSerializer.Serialize(playbook, new JsonSerializerOptions { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }); var filePath = Path.Combine(playbookDir, fileName); await TextFileCodec.WriteAllTextAsync(filePath, json, TextFileCodec.Utf8NoBom, ct); return ToolResult.Ok($"플레이북 저장 완료: [{nextId}] {name}\n단계 수: {steps.Count}, 사용 도구: {toolsUsed.Count}개"); } catch (Exception ex) { return ToolResult.Fail($"플레이북 저장 오류: {ex.Message}"); } } private static ToolResult ListPlaybooks(string playbookDir) { if (!Directory.Exists(playbookDir)) return ToolResult.Ok("저장된 플레이북이 없습니다."); var files = Directory.GetFiles(playbookDir, "*.json") .OrderBy(f => f) .ToList(); if (files.Count == 0) return ToolResult.Ok("저장된 플레이북이 없습니다."); var sb = new StringBuilder(); sb.AppendLine($"플레이북 {files.Count}개:"); foreach (var file in files) { try { var json = TextFileCodec.ReadAllText(file).Text; var pb = JsonSerializer.Deserialize(json); if (pb != null) sb.AppendLine($" [{pb.Id}] {pb.Name} — {pb.Description} ({pb.Steps.Count}단계, {pb.CreatedAt})"); } catch { sb.AppendLine($" [?] {Path.GetFileName(file)} — 파싱 오류"); } } return ToolResult.Ok(sb.ToString()); } private static async Task DescribePlaybook(JsonElement args, string playbookDir, CancellationToken ct) { if (!args.TryGetProperty("id", out var idEl)) return ToolResult.Fail("플레이북 id가 필요합니다."); var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1; var playbook = await FindPlaybookById(playbookDir, id, ct); if (playbook == null) return ToolResult.Fail($"ID {id}의 플레이북을 찾을 수 없습니다."); var sb = new StringBuilder(); sb.AppendLine($"플레이북: {playbook.Name} (ID: {playbook.Id})"); sb.AppendLine($"설명: {playbook.Description}"); sb.AppendLine($"생성일: {playbook.CreatedAt}"); sb.AppendLine(); sb.AppendLine("단계:"); for (var i = 0; i < playbook.Steps.Count; i++) sb.AppendLine($" {i + 1}. {playbook.Steps[i]}"); if (playbook.ToolsUsed.Count > 0) { sb.AppendLine(); sb.AppendLine($"사용 도구: {string.Join(", ", playbook.ToolsUsed)}"); } return ToolResult.Ok(sb.ToString()); } private static ToolResult DeletePlaybook(JsonElement args, string playbookDir) { if (!args.TryGetProperty("id", out var idEl)) return ToolResult.Fail("삭제할 플레이북 id가 필요합니다."); var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1; if (!Directory.Exists(playbookDir)) return ToolResult.Fail("저장된 플레이북이 없습니다."); // ID에 해당하는 파일 찾기 var files = Directory.GetFiles(playbookDir, $"{id}_*.json"); if (files.Length == 0) { // 파일 내부 ID로 검색 foreach (var file in Directory.GetFiles(playbookDir, "*.json")) { try { var json = TextFileCodec.ReadAllText(file).Text; var pb = JsonSerializer.Deserialize(json); if (pb?.Id == id) { var name = pb.Name; File.Delete(file); return ToolResult.Ok($"플레이북 삭제됨: [{id}] {name}"); } } catch { /* 무시 */ } } return ToolResult.Fail($"ID {id}의 플레이북을 찾을 수 없습니다."); } try { var fileName = Path.GetFileNameWithoutExtension(files[0]); File.Delete(files[0]); return ToolResult.Ok($"플레이북 삭제됨: {fileName}"); } catch (Exception ex) { return ToolResult.Fail($"플레이북 삭제 오류: {ex.Message}"); } } #region Helpers private static int GetNextId(string playbookDir) { if (!Directory.Exists(playbookDir)) return 1; var maxId = 0; foreach (var file in Directory.GetFiles(playbookDir, "*.json")) { try { var json = TextFileCodec.ReadAllText(file).Text; var pb = JsonSerializer.Deserialize(json); if (pb != null && pb.Id > maxId) maxId = pb.Id; } catch { /* 무시 */ } } return maxId + 1; } private static async Task FindPlaybookById(string playbookDir, int id, CancellationToken ct) { if (!Directory.Exists(playbookDir)) return null; foreach (var file in Directory.GetFiles(playbookDir, "*.json")) { try { var json = (await TextFileCodec.ReadAllTextAsync(file, ct)).Text; var pb = JsonSerializer.Deserialize(json); if (pb?.Id == id) return pb; } catch { /* 무시 */ } } return null; } #endregion #region Models private class PlaybookData { public int Id { get; set; } public string Name { get; set; } = ""; public string Description { get; set; } = ""; public List Steps { get; set; } = []; public List ToolsUsed { get; set; } = []; public string CreatedAt { get; set; } = ""; } #endregion }