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

325 lines
11 KiB
C#

using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 성공적인 실행 흐름을 재사용 가능한 플레이북으로 저장/관리하는 도구.
/// .ax/playbooks/ 에 JSON 파일로 저장됩니다.
/// </summary>
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<ToolResult> 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<ToolResult> 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<string>();
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<string>();
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<PlaybookData>(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<ToolResult> 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<PlaybookData>(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<PlaybookData>(json);
if (pb != null && pb.Id > maxId)
maxId = pb.Id;
}
catch { /* 무시 */ }
}
return maxId + 1;
}
private static async Task<PlaybookData?> 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<PlaybookData>(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<string> Steps { get; set; } = [];
public List<string> ToolsUsed { get; set; } = [];
public string CreatedAt { get; set; } = "";
}
#endregion
}