325 lines
11 KiB
C#
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
|
|
}
|