claw-code 동등 품질 4단계 연속 반영: Agentic 루프/상태복원/설정연동/릴리즈 게이트 정렬
Some checks failed
Release Gate / gate (push) Has been cancelled
Some checks failed
Release Gate / gate (push) Has been cancelled
- 도구 동등화: task/todo/tool-search + plan/worktree/team/cron 도구군 추가 및 ToolRegistry 등록\n- claw-code CamelCase 별칭 정규화 확장: EnterPlanMode/EnterWorktree/TeamCreate/CronCreate 등 -> 내부 snake_case 매핑\n- AgentLoop 런타임 강화: Code 탭 전용 도구 토글(CodeSettings) 반영, 비활성 도구 자동 차단\n- Worktree 상태 복원 연결: .ax/worktree_state.json 기반 루트 탐색/활성 worktree 복원 및 BuildContext 연동\n- 권한/플러그인 하드닝 기존 반영분 유지: target 기반 권한 판정 + internal 모드 플러그인 경로/manifest 검증\n- 설정 연동(UI): SettingsWindow Code 패널에 Plan/Worktree/Team/Cron 도구 on/off 토글 추가\n- 테스트 보강: AgentParityTools/AgentLoopE2E에 worktree 지속성, alias 정규화, 설정 차단 시나리오 추가\n- 검증 완료: dotnet build(경고0/오류0), ParityBenchmark 11/11, ReplayStability 12/12, 전체 371/371, release-gate 통과\n- 문서 동기화: AGENT_ROADMAP/NEXT_ROADMAP/CLAW_CODE_PARITY_PLAN 수치 및 기준 최신화
This commit is contained in:
@@ -94,7 +94,7 @@ public partial class AgentLoopService
|
||||
{
|
||||
EmitEvent(AgentEventType.Thinking, "", $"읽기 전용 도구 {calls.Count}개를 병렬 실행 중...");
|
||||
|
||||
var activeToolNames = _tools.GetActiveTools(llm.DisabledTools)
|
||||
var activeToolNames = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides: null)
|
||||
.Select(t => t.Name)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
|
||||
@@ -1536,7 +1536,8 @@ public partial class AgentLoopService
|
||||
IEnumerable<string>? disabledToolNames,
|
||||
SkillRuntimeOverrides? runtimeOverrides)
|
||||
{
|
||||
var active = _tools.GetActiveTools(disabledToolNames);
|
||||
var mergedDisabled = MergeDisabledTools(disabledToolNames);
|
||||
var active = _tools.GetActiveTools(mergedDisabled);
|
||||
if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0)
|
||||
return active;
|
||||
|
||||
@@ -1546,6 +1547,50 @@ public partial class AgentLoopService
|
||||
.AsReadOnly();
|
||||
}
|
||||
|
||||
private IEnumerable<string> MergeDisabledTools(IEnumerable<string>? disabledToolNames)
|
||||
{
|
||||
var disabled = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (disabledToolNames != null)
|
||||
{
|
||||
foreach (var name in disabledToolNames)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
disabled.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.Equals(ActiveTab, "Code", StringComparison.OrdinalIgnoreCase))
|
||||
return disabled;
|
||||
|
||||
var code = _settings.Settings.Llm.Code;
|
||||
if (!code.EnablePlanModeTools)
|
||||
{
|
||||
disabled.Add("enter_plan_mode");
|
||||
disabled.Add("exit_plan_mode");
|
||||
}
|
||||
|
||||
if (!code.EnableWorktreeTools)
|
||||
{
|
||||
disabled.Add("enter_worktree");
|
||||
disabled.Add("exit_worktree");
|
||||
}
|
||||
|
||||
if (!code.EnableTeamTools)
|
||||
{
|
||||
disabled.Add("team_create");
|
||||
disabled.Add("team_delete");
|
||||
}
|
||||
|
||||
if (!code.EnableCronTools)
|
||||
{
|
||||
disabled.Add("cron_create");
|
||||
disabled.Add("cron_delete");
|
||||
disabled.Add("cron_list");
|
||||
}
|
||||
|
||||
return disabled;
|
||||
}
|
||||
|
||||
private static HashSet<string> ParseAllowedToolNames(string? raw)
|
||||
{
|
||||
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -3510,6 +3555,25 @@ public partial class AgentLoopService
|
||||
["task"] = "spawn_agent",
|
||||
["sendmessage"] = "notify_tool",
|
||||
["powershell"] = "process",
|
||||
["toolsearch"] = "tool_search",
|
||||
["todowrite"] = "todo_write",
|
||||
["taskcreate"] = "task_create",
|
||||
["taskget"] = "task_get",
|
||||
["tasklist"] = "task_list",
|
||||
["taskupdate"] = "task_update",
|
||||
["taskstop"] = "task_stop",
|
||||
["taskoutput"] = "task_output",
|
||||
["enterplanmode"] = "enter_plan_mode",
|
||||
["exitplanmode"] = "exit_plan_mode",
|
||||
["enterworktree"] = "enter_worktree",
|
||||
["exitworktree"] = "exit_worktree",
|
||||
["teamcreate"] = "team_create",
|
||||
["teamdelete"] = "team_delete",
|
||||
["croncreate"] = "cron_create",
|
||||
["crondelete"] = "cron_delete",
|
||||
["cronlist"] = "cron_list",
|
||||
["config"] = "project_rules",
|
||||
["skill"] = "skill_manager",
|
||||
};
|
||||
|
||||
private static string ResolveRequestedToolName(string requestedToolName, IReadOnlyCollection<string> activeToolNames)
|
||||
@@ -3858,9 +3922,11 @@ public partial class AgentLoopService
|
||||
private AgentContext BuildContext()
|
||||
{
|
||||
var llm = _settings.Settings.Llm;
|
||||
var baseWorkFolder = llm.WorkFolder;
|
||||
var runtimeWorkFolder = ResolveRuntimeWorkFolder(baseWorkFolder);
|
||||
return new AgentContext
|
||||
{
|
||||
WorkFolder = llm.WorkFolder,
|
||||
WorkFolder = runtimeWorkFolder,
|
||||
Permission = llm.FilePermission,
|
||||
BlockedPaths = llm.BlockedPaths,
|
||||
BlockedExtensions = llm.BlockedExtensions,
|
||||
@@ -3875,6 +3941,34 @@ public partial class AgentLoopService
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveRuntimeWorkFolder(string? configuredRoot)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredRoot))
|
||||
return "";
|
||||
|
||||
try
|
||||
{
|
||||
var root = Path.GetFullPath(configuredRoot);
|
||||
if (!Directory.Exists(root))
|
||||
return root;
|
||||
|
||||
var state = WorktreeStateStore.Load(root);
|
||||
if (!string.IsNullOrWhiteSpace(state.Active))
|
||||
{
|
||||
var active = Path.GetFullPath(state.Active);
|
||||
if (Directory.Exists(active)
|
||||
&& active.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
||||
return active;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return configuredRoot ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeToolTarget(string toolName, JsonElement input, AgentContext context)
|
||||
{
|
||||
static string? TryReadString(JsonElement inputElement, params string[] names)
|
||||
@@ -4049,7 +4143,7 @@ public partial class AgentLoopService
|
||||
runId = _currentRunId,
|
||||
tool = toolName,
|
||||
target,
|
||||
permission = context.GetEffectiveToolPermission(toolName)
|
||||
permission = context.GetEffectiveToolPermission(toolName, target)
|
||||
});
|
||||
await RunPermissionLifecycleHooksAsync(
|
||||
"__permission_request__",
|
||||
@@ -4059,7 +4153,7 @@ public partial class AgentLoopService
|
||||
messages,
|
||||
success: true);
|
||||
|
||||
var effectivePerm = context.GetEffectiveToolPermission(toolName);
|
||||
var effectivePerm = context.GetEffectiveToolPermission(toolName, target);
|
||||
|
||||
if (string.Equals(effectivePerm, "Ask", StringComparison.OrdinalIgnoreCase))
|
||||
EmitEvent(AgentEventType.PermissionRequest, toolName, $"권한 확인 필요 · 대상: {target}");
|
||||
|
||||
38
src/AxCopilot/Services/Agent/CronCreateTool.cs
Normal file
38
src/AxCopilot/Services/Agent/CronCreateTool.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class CronCreateTool : IAgentTool
|
||||
{
|
||||
public string Name => "cron_create";
|
||||
public string Description => "Create a local cron-like job descriptor.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["name"] = new() { Type = "string", Description = "Job name." },
|
||||
["schedule"] = new() { Type = "string", Description = "Schedule expression (e.g. every 2h)." },
|
||||
["command"] = new() { Type = "string", Description = "Command or task description." }
|
||||
},
|
||||
Required = ["name", "schedule", "command"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var name = args.TryGetProperty("name", out var n) ? (n.GetString() ?? "").Trim() : "";
|
||||
var schedule = args.TryGetProperty("schedule", out var s) ? (s.GetString() ?? "").Trim() : "";
|
||||
var command = args.TryGetProperty("command", out var c) ? (c.GetString() ?? "").Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(schedule) || string.IsNullOrWhiteSpace(command))
|
||||
return Task.FromResult(ToolResult.Fail("name, schedule, command are required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var jobs = CronStore.Load(context.WorkFolder);
|
||||
var job = new CronStore.CronJob { Name = name, Schedule = schedule, Command = command, Enabled = true };
|
||||
jobs.Add(job);
|
||||
CronStore.Save(context.WorkFolder, jobs);
|
||||
return Task.FromResult(ToolResult.Ok($"Created cron job: {job.Name} ({job.Id})"));
|
||||
}
|
||||
}
|
||||
38
src/AxCopilot/Services/Agent/CronDeleteTool.cs
Normal file
38
src/AxCopilot/Services/Agent/CronDeleteTool.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class CronDeleteTool : IAgentTool
|
||||
{
|
||||
public string Name => "cron_delete";
|
||||
public string Description => "Delete a local cron-like job descriptor by id or name.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["id"] = new() { Type = "string", Description = "Job id." },
|
||||
["name"] = new() { Type = "string", Description = "Job name (fallback)." }
|
||||
},
|
||||
Required = []
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var id = args.TryGetProperty("id", out var idEl) ? (idEl.GetString() ?? "").Trim() : "";
|
||||
var name = args.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "").Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(name))
|
||||
return Task.FromResult(ToolResult.Fail("id or name is required."));
|
||||
|
||||
var jobs = CronStore.Load(context.WorkFolder);
|
||||
var removed = jobs.RemoveAll(j =>
|
||||
(!string.IsNullOrWhiteSpace(id) && string.Equals(j.Id, id, StringComparison.OrdinalIgnoreCase))
|
||||
|| (!string.IsNullOrWhiteSpace(name) && string.Equals(j.Name, name, StringComparison.OrdinalIgnoreCase)));
|
||||
CronStore.Save(context.WorkFolder, jobs);
|
||||
return Task.FromResult(ToolResult.Ok(removed > 0 ? $"Deleted {removed} cron job(s)." : "No matching cron job found."));
|
||||
}
|
||||
}
|
||||
36
src/AxCopilot/Services/Agent/CronListTool.cs
Normal file
36
src/AxCopilot/Services/Agent/CronListTool.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class CronListTool : IAgentTool
|
||||
{
|
||||
public string Name => "cron_list";
|
||||
public string Description => "List local cron-like jobs.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new(),
|
||||
Required = []
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var jobs = CronStore.Load(context.WorkFolder)
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.ToList();
|
||||
if (jobs.Count == 0)
|
||||
return Task.FromResult(ToolResult.Ok("No cron jobs."));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"cron jobs: {jobs.Count}");
|
||||
foreach (var j in jobs)
|
||||
sb.AppendLine($"- {j.Id} | {j.Name} | {j.Schedule} | enabled={j.Enabled} | {j.Command}");
|
||||
return Task.FromResult(ToolResult.Ok(sb.ToString()));
|
||||
}
|
||||
}
|
||||
58
src/AxCopilot/Services/Agent/CronStore.cs
Normal file
58
src/AxCopilot/Services/Agent/CronStore.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
internal static class CronStore
|
||||
{
|
||||
private const string CronRelativePath = ".ax/cron.json";
|
||||
|
||||
internal sealed class CronJob
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("schedule")]
|
||||
public string Schedule { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("command")]
|
||||
public string Command { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
|
||||
internal static string GetPath(string root) => Path.Combine(root, CronRelativePath);
|
||||
|
||||
internal static List<CronJob> Load(string root)
|
||||
{
|
||||
var path = GetPath(root);
|
||||
if (!File.Exists(path))
|
||||
return [];
|
||||
try
|
||||
{
|
||||
var json = TextFileCodec.ReadAllText(path).Text;
|
||||
return JsonSerializer.Deserialize<List<CronJob>>(json) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
internal static void Save(string root, List<CronJob> jobs)
|
||||
{
|
||||
var path = GetPath(root);
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(jobs, new JsonSerializerOptions { WriteIndented = true }), TextFileCodec.Utf8NoBom);
|
||||
}
|
||||
}
|
||||
28
src/AxCopilot/Services/Agent/EnterPlanModeTool.cs
Normal file
28
src/AxCopilot/Services/Agent/EnterPlanModeTool.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class EnterPlanModeTool : IAgentTool
|
||||
{
|
||||
public string Name => "enter_plan_mode";
|
||||
public string Description => "Enable plan mode marker for current workspace.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new(),
|
||||
Required = []
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
var path = Path.Combine(context.WorkFolder, ".ax", "plan_mode.state");
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(path, "on", TextFileCodec.Utf8NoBom);
|
||||
return Task.FromResult(ToolResult.Ok("Plan mode enabled."));
|
||||
}
|
||||
}
|
||||
48
src/AxCopilot/Services/Agent/EnterWorktreeTool.cs
Normal file
48
src/AxCopilot/Services/Agent/EnterWorktreeTool.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class EnterWorktreeTool : IAgentTool
|
||||
{
|
||||
public string Name => "enter_worktree";
|
||||
public string Description => "Enter or create a nested worktree path inside current WorkFolder.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "Relative worktree path." },
|
||||
["create"] = new() { Type = "boolean", Description = "Create directory if missing (default true)." }
|
||||
},
|
||||
Required = ["path"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var relative = args.TryGetProperty("path", out var pathEl) ? (pathEl.GetString() ?? "").Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(relative))
|
||||
return Task.FromResult(ToolResult.Fail("path is required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var create = !args.TryGetProperty("create", out var createEl) || createEl.ValueKind != JsonValueKind.False;
|
||||
var root = WorktreeStateStore.ResolveRoot(context.WorkFolder);
|
||||
var full = Path.GetFullPath(Path.Combine(root, relative));
|
||||
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
||||
return Task.FromResult(ToolResult.Fail("worktree path must stay inside WorkFolder."));
|
||||
|
||||
if (!Directory.Exists(full))
|
||||
{
|
||||
if (!create)
|
||||
return Task.FromResult(ToolResult.Fail("worktree path does not exist."));
|
||||
Directory.CreateDirectory(full);
|
||||
}
|
||||
|
||||
var state = WorktreeStateStore.Load(root);
|
||||
state.Active = full;
|
||||
WorktreeStateStore.Save(root, state);
|
||||
context.WorkFolder = full;
|
||||
return Task.FromResult(ToolResult.Ok($"Entered worktree: {full}"));
|
||||
}
|
||||
}
|
||||
26
src/AxCopilot/Services/Agent/ExitPlanModeTool.cs
Normal file
26
src/AxCopilot/Services/Agent/ExitPlanModeTool.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class ExitPlanModeTool : IAgentTool
|
||||
{
|
||||
public string Name => "exit_plan_mode";
|
||||
public string Description => "Disable plan mode marker for current workspace.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new(),
|
||||
Required = []
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
var path = Path.Combine(context.WorkFolder, ".ax", "plan_mode.state");
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
return Task.FromResult(ToolResult.Ok("Plan mode disabled."));
|
||||
}
|
||||
}
|
||||
29
src/AxCopilot/Services/Agent/ExitWorktreeTool.cs
Normal file
29
src/AxCopilot/Services/Agent/ExitWorktreeTool.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class ExitWorktreeTool : IAgentTool
|
||||
{
|
||||
public string Name => "exit_worktree";
|
||||
public string Description => "Exit current worktree and return to root WorkFolder state.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new(),
|
||||
Required = []
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var root = WorktreeStateStore.ResolveRoot(context.WorkFolder);
|
||||
var state = WorktreeStateStore.Load(root);
|
||||
state.Active = root;
|
||||
WorktreeStateStore.Save(root, state);
|
||||
context.WorkFolder = root;
|
||||
return Task.FromResult(ToolResult.Ok($"Exited worktree. Active root: {root}"));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
@@ -95,7 +96,7 @@ public class AgentContext
|
||||
private readonly object _permissionLock = new();
|
||||
private readonly HashSet<string> _approvedPermissionCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>작업 폴더 경로.</summary>
|
||||
public string WorkFolder { get; init; } = "";
|
||||
public string WorkFolder { get; set; } = "";
|
||||
|
||||
/// <summary>파일 접근 권한. Ask | Auto | Deny</summary>
|
||||
public string Permission { get; init; } = "Ask";
|
||||
@@ -186,8 +187,13 @@ public class AgentContext
|
||||
return await CheckToolPermissionAsync(toolName, filePath);
|
||||
}
|
||||
|
||||
public string GetEffectiveToolPermission(string toolName)
|
||||
public string GetEffectiveToolPermission(string toolName) => GetEffectiveToolPermission(toolName, null);
|
||||
|
||||
public string GetEffectiveToolPermission(string toolName, string? target)
|
||||
{
|
||||
if (TryResolvePatternPermission(toolName, target, out var patternPermission))
|
||||
return patternPermission;
|
||||
|
||||
if (ToolPermissions.TryGetValue(toolName, out var toolPerm) &&
|
||||
!string.IsNullOrWhiteSpace(toolPerm))
|
||||
return toolPerm;
|
||||
@@ -207,7 +213,7 @@ public class AgentContext
|
||||
&& AxCopilot.Services.OperationModePolicy.IsBlockedAgentToolInInternalMode(toolName, target))
|
||||
return false;
|
||||
|
||||
var effectivePerm = GetEffectiveToolPermission(toolName);
|
||||
var effectivePerm = GetEffectiveToolPermission(toolName, target);
|
||||
if (string.Equals(effectivePerm, "Deny", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (string.Equals(effectivePerm, "Auto", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (AskPermission == null) return false;
|
||||
@@ -228,6 +234,67 @@ public class AgentContext
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
private bool TryResolvePatternPermission(string toolName, string? target, out string permission)
|
||||
{
|
||||
permission = "";
|
||||
if (ToolPermissions.Count == 0 || string.IsNullOrWhiteSpace(target))
|
||||
return false;
|
||||
|
||||
var normalizedTool = toolName.Trim();
|
||||
var normalizedTarget = target.Trim();
|
||||
|
||||
foreach (var kv in ToolPermissions)
|
||||
{
|
||||
if (TryParsePatternRule(kv.Key, out var ruleTool, out var rulePattern)
|
||||
&& string.Equals(ruleTool, normalizedTool, StringComparison.OrdinalIgnoreCase)
|
||||
&& WildcardMatch(normalizedTarget, rulePattern)
|
||||
&& !string.IsNullOrWhiteSpace(kv.Value))
|
||||
{
|
||||
permission = kv.Value.Trim();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var kv in ToolPermissions)
|
||||
{
|
||||
if (TryParsePatternRule(kv.Key, out var ruleTool, out var rulePattern)
|
||||
&& string.Equals(ruleTool, "*", StringComparison.Ordinal)
|
||||
&& WildcardMatch(normalizedTarget, rulePattern)
|
||||
&& !string.IsNullOrWhiteSpace(kv.Value))
|
||||
{
|
||||
permission = kv.Value.Trim();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParsePatternRule(string? key, out string ruleTool, out string rulePattern)
|
||||
{
|
||||
ruleTool = "";
|
||||
rulePattern = "";
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
return false;
|
||||
|
||||
var trimmed = key.Trim();
|
||||
var at = trimmed.IndexOf('@');
|
||||
if (at <= 0 || at == trimmed.Length - 1)
|
||||
return false;
|
||||
|
||||
ruleTool = trimmed[..at].Trim();
|
||||
rulePattern = trimmed[(at + 1)..].Trim();
|
||||
return !string.IsNullOrWhiteSpace(ruleTool) && !string.IsNullOrWhiteSpace(rulePattern);
|
||||
}
|
||||
|
||||
private static bool WildcardMatch(string input, string pattern)
|
||||
{
|
||||
var regex = "^" + Regex.Escape(pattern)
|
||||
.Replace("\\*", ".*")
|
||||
.Replace("\\?", ".") + "$";
|
||||
return Regex.IsMatch(input, regex, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>에이전트 이벤트 (UI 표시용).</summary>
|
||||
|
||||
@@ -462,6 +462,23 @@ public static class SkillService
|
||||
["LSP"] = "lsp_code_intel",
|
||||
["ListMcpResourcesTool"] = "mcp_list_resources",
|
||||
["ReadMcpResourceTool"] = "mcp_read_resource",
|
||||
["ToolSearch"] = "tool_search",
|
||||
["TodoWrite"] = "todo_write",
|
||||
["TaskCreate"] = "task_create",
|
||||
["TaskGet"] = "task_get",
|
||||
["TaskList"] = "task_list",
|
||||
["TaskUpdate"] = "task_update",
|
||||
["TaskStop"] = "task_stop",
|
||||
["TaskOutput"] = "task_output",
|
||||
["EnterPlanMode"] = "enter_plan_mode",
|
||||
["ExitPlanMode"] = "exit_plan_mode",
|
||||
["EnterWorktree"] = "enter_worktree",
|
||||
["ExitWorktree"] = "exit_worktree",
|
||||
["TeamCreate"] = "team_create",
|
||||
["TeamDelete"] = "team_delete",
|
||||
["CronCreate"] = "cron_create",
|
||||
["CronDelete"] = "cron_delete",
|
||||
["CronList"] = "cron_list",
|
||||
["Agent"] = "spawn_agent",
|
||||
["Task"] = "spawn_agent",
|
||||
["SendMessage"] = "notify_tool",
|
||||
|
||||
81
src/AxCopilot/Services/Agent/TaskBoardStore.cs
Normal file
81
src/AxCopilot/Services/Agent/TaskBoardStore.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
internal static class TaskBoardStore
|
||||
{
|
||||
private const string StoreRelativePath = ".ax/taskboard.json";
|
||||
|
||||
internal sealed class TaskItem
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "open"; // open | in_progress | blocked | done | stopped
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public string Priority { get; set; } = "medium"; // high | medium | low
|
||||
|
||||
[JsonPropertyName("output")]
|
||||
public string Output { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
|
||||
internal static string GetStorePath(string workFolder)
|
||||
=> Path.Combine(workFolder, StoreRelativePath);
|
||||
|
||||
internal static List<TaskItem> Load(string workFolder)
|
||||
{
|
||||
var path = GetStorePath(workFolder);
|
||||
if (!File.Exists(path))
|
||||
return [];
|
||||
|
||||
try
|
||||
{
|
||||
var json = TextFileCodec.ReadAllText(path).Text;
|
||||
return JsonSerializer.Deserialize<List<TaskItem>>(json) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
internal static void Save(string workFolder, List<TaskItem> tasks)
|
||||
{
|
||||
var path = GetStorePath(workFolder);
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var json = JsonSerializer.Serialize(tasks, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
File.WriteAllText(path, json, TextFileCodec.Utf8NoBom);
|
||||
}
|
||||
|
||||
internal static int NextId(List<TaskItem> tasks)
|
||||
=> tasks.Count == 0 ? 1 : tasks.Max(t => t.Id) + 1;
|
||||
|
||||
internal static bool IsValidStatus(string value)
|
||||
=> value is "open" or "in_progress" or "blocked" or "done" or "stopped";
|
||||
|
||||
internal static bool IsValidPriority(string value)
|
||||
=> value is "high" or "medium" or "low";
|
||||
}
|
||||
53
src/AxCopilot/Services/Agent/TaskCreateTool.cs
Normal file
53
src/AxCopilot/Services/Agent/TaskCreateTool.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class TaskCreateTool : IAgentTool
|
||||
{
|
||||
public string Name => "task_create";
|
||||
|
||||
public string Description =>
|
||||
"Create a tracked task in the workspace task board (.ax/taskboard.json).";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["title"] = new() { Type = "string", Description = "Task title" },
|
||||
["description"] = new() { Type = "string", Description = "Task description (optional)" },
|
||||
["priority"] = new() { Type = "string", Description = "high | medium | low", Enum = ["high", "medium", "low"] },
|
||||
},
|
||||
Required = ["title"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var title = args.TryGetProperty("title", out var titleEl) ? (titleEl.GetString() ?? "").Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return Task.FromResult(ToolResult.Fail("title is required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var description = args.TryGetProperty("description", out var descEl) ? (descEl.GetString() ?? "").Trim() : "";
|
||||
var priority = args.TryGetProperty("priority", out var priEl) ? (priEl.GetString() ?? "medium").Trim().ToLowerInvariant() : "medium";
|
||||
if (!TaskBoardStore.IsValidPriority(priority))
|
||||
priority = "medium";
|
||||
|
||||
var tasks = TaskBoardStore.Load(context.WorkFolder);
|
||||
var id = TaskBoardStore.NextId(tasks);
|
||||
tasks.Add(new TaskBoardStore.TaskItem
|
||||
{
|
||||
Id = id,
|
||||
Title = title,
|
||||
Description = description,
|
||||
Priority = priority,
|
||||
Status = "open",
|
||||
CreatedAt = DateTime.Now,
|
||||
UpdatedAt = DateTime.Now
|
||||
});
|
||||
TaskBoardStore.Save(context.WorkFolder, tasks);
|
||||
|
||||
return Task.FromResult(ToolResult.Ok($"Created task #{id}: {title} [{priority}]"));
|
||||
}
|
||||
}
|
||||
46
src/AxCopilot/Services/Agent/TaskGetTool.cs
Normal file
46
src/AxCopilot/Services/Agent/TaskGetTool.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class TaskGetTool : IAgentTool
|
||||
{
|
||||
public string Name => "task_get";
|
||||
|
||||
public string Description => "Get a task by id from the workspace task board.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["id"] = new() { Type = "integer", Description = "Task id" }
|
||||
},
|
||||
Required = ["id"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (!args.TryGetProperty("id", out var idEl))
|
||||
return Task.FromResult(ToolResult.Fail("id is required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var id = idEl.GetInt32();
|
||||
var task = TaskBoardStore.Load(context.WorkFolder).FirstOrDefault(t => t.Id == id);
|
||||
if (task == null)
|
||||
return Task.FromResult(ToolResult.Fail($"Task #{id} not found."));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"#{task.Id} {task.Title}");
|
||||
sb.AppendLine($"status: {task.Status}");
|
||||
sb.AppendLine($"priority: {task.Priority}");
|
||||
if (!string.IsNullOrWhiteSpace(task.Description))
|
||||
sb.AppendLine($"description: {task.Description}");
|
||||
if (!string.IsNullOrWhiteSpace(task.Output))
|
||||
sb.AppendLine($"output: {task.Output}");
|
||||
sb.AppendLine($"updatedAt: {task.UpdatedAt:yyyy-MM-dd HH:mm:ss}");
|
||||
return Task.FromResult(ToolResult.Ok(sb.ToString()));
|
||||
}
|
||||
}
|
||||
48
src/AxCopilot/Services/Agent/TaskListTool.cs
Normal file
48
src/AxCopilot/Services/Agent/TaskListTool.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class TaskListTool : IAgentTool
|
||||
{
|
||||
public string Name => "task_list";
|
||||
|
||||
public string Description => "List tasks from the workspace task board.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["status"] = new() { Type = "string", Description = "Filter by status (optional)." }
|
||||
},
|
||||
Required = []
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var status = args.TryGetProperty("status", out var statusEl) ? (statusEl.GetString() ?? "").Trim().ToLowerInvariant() : "";
|
||||
var tasks = TaskBoardStore.Load(context.WorkFolder);
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
tasks = tasks.Where(t => string.Equals(t.Status, status, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (tasks.Count == 0)
|
||||
return Task.FromResult(ToolResult.Ok("No tasks."));
|
||||
|
||||
var ordered = tasks
|
||||
.OrderBy(t => t.Status == "done" || t.Status == "stopped" ? 1 : 0)
|
||||
.ThenByDescending(t => t.Priority == "high" ? 3 : t.Priority == "medium" ? 2 : 1)
|
||||
.ThenByDescending(t => t.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"tasks: {ordered.Count}");
|
||||
foreach (var t in ordered)
|
||||
sb.AppendLine($"#{t.Id} [{t.Status}] [{t.Priority}] {t.Title}");
|
||||
return Task.FromResult(ToolResult.Ok(sb.ToString()));
|
||||
}
|
||||
}
|
||||
52
src/AxCopilot/Services/Agent/TaskOutputTool.cs
Normal file
52
src/AxCopilot/Services/Agent/TaskOutputTool.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class TaskOutputTool : IAgentTool
|
||||
{
|
||||
public string Name => "task_output";
|
||||
|
||||
public string Description =>
|
||||
"Write task progress/output text. Useful for long-running or delegated task traces.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["id"] = new() { Type = "integer", Description = "Task id" },
|
||||
["output"] = new() { Type = "string", Description = "Output text" },
|
||||
["append"] = new() { Type = "boolean", Description = "Append output instead of replace (default: true)" }
|
||||
},
|
||||
Required = ["id", "output"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (!args.TryGetProperty("id", out var idEl))
|
||||
return Task.FromResult(ToolResult.Fail("id is required."));
|
||||
if (!args.TryGetProperty("output", out var outEl))
|
||||
return Task.FromResult(ToolResult.Fail("output is required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var id = idEl.GetInt32();
|
||||
var output = (outEl.GetString() ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(output))
|
||||
return Task.FromResult(ToolResult.Fail("output is empty."));
|
||||
|
||||
var append = !args.TryGetProperty("append", out var appendEl) || appendEl.ValueKind != JsonValueKind.False;
|
||||
var tasks = TaskBoardStore.Load(context.WorkFolder);
|
||||
var task = tasks.FirstOrDefault(t => t.Id == id);
|
||||
if (task == null)
|
||||
return Task.FromResult(ToolResult.Fail($"Task #{id} not found."));
|
||||
|
||||
task.Output = append && !string.IsNullOrWhiteSpace(task.Output)
|
||||
? $"{task.Output}\n{output}"
|
||||
: output;
|
||||
task.UpdatedAt = DateTime.Now;
|
||||
TaskBoardStore.Save(context.WorkFolder, tasks);
|
||||
return Task.FromResult(ToolResult.Ok($"Updated output for task #{task.Id}."));
|
||||
}
|
||||
}
|
||||
44
src/AxCopilot/Services/Agent/TaskStopTool.cs
Normal file
44
src/AxCopilot/Services/Agent/TaskStopTool.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class TaskStopTool : IAgentTool
|
||||
{
|
||||
public string Name => "task_stop";
|
||||
|
||||
public string Description => "Stop a task (marks status=stopped) on the workspace task board.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["id"] = new() { Type = "integer", Description = "Task id" },
|
||||
["reason"] = new() { Type = "string", Description = "Stop reason (optional)." }
|
||||
},
|
||||
Required = ["id"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (!args.TryGetProperty("id", out var idEl))
|
||||
return Task.FromResult(ToolResult.Fail("id is required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var id = idEl.GetInt32();
|
||||
var reason = args.TryGetProperty("reason", out var reasonEl) ? (reasonEl.GetString() ?? "").Trim() : "";
|
||||
var tasks = TaskBoardStore.Load(context.WorkFolder);
|
||||
var task = tasks.FirstOrDefault(t => t.Id == id);
|
||||
if (task == null)
|
||||
return Task.FromResult(ToolResult.Fail($"Task #{id} not found."));
|
||||
|
||||
task.Status = "stopped";
|
||||
if (!string.IsNullOrWhiteSpace(reason))
|
||||
task.Output = string.IsNullOrWhiteSpace(task.Output) ? $"stop reason: {reason}" : $"{task.Output}\nstop reason: {reason}";
|
||||
task.UpdatedAt = DateTime.Now;
|
||||
TaskBoardStore.Save(context.WorkFolder, tasks);
|
||||
return Task.FromResult(ToolResult.Ok($"Stopped task #{task.Id}: {task.Title}"));
|
||||
}
|
||||
}
|
||||
70
src/AxCopilot/Services/Agent/TaskUpdateTool.cs
Normal file
70
src/AxCopilot/Services/Agent/TaskUpdateTool.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class TaskUpdateTool : IAgentTool
|
||||
{
|
||||
public string Name => "task_update";
|
||||
|
||||
public string Description =>
|
||||
"Update task fields (status/title/description/priority) in the workspace task board.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["id"] = new() { Type = "integer", Description = "Task id" },
|
||||
["status"] = new() { Type = "string", Description = "open | in_progress | blocked | done | stopped" },
|
||||
["title"] = new() { Type = "string", Description = "New title (optional)" },
|
||||
["description"] = new() { Type = "string", Description = "New description (optional)" },
|
||||
["priority"] = new() { Type = "string", Description = "high | medium | low" },
|
||||
},
|
||||
Required = ["id"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (!args.TryGetProperty("id", out var idEl))
|
||||
return Task.FromResult(ToolResult.Fail("id is required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var id = idEl.GetInt32();
|
||||
var tasks = TaskBoardStore.Load(context.WorkFolder);
|
||||
var task = tasks.FirstOrDefault(t => t.Id == id);
|
||||
if (task == null)
|
||||
return Task.FromResult(ToolResult.Fail($"Task #{id} not found."));
|
||||
|
||||
if (args.TryGetProperty("status", out var statusEl))
|
||||
{
|
||||
var status = (statusEl.GetString() ?? "").Trim().ToLowerInvariant();
|
||||
if (!TaskBoardStore.IsValidStatus(status))
|
||||
return Task.FromResult(ToolResult.Fail("Invalid status."));
|
||||
task.Status = status;
|
||||
}
|
||||
|
||||
if (args.TryGetProperty("title", out var titleEl))
|
||||
{
|
||||
var title = (titleEl.GetString() ?? "").Trim();
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
task.Title = title;
|
||||
}
|
||||
|
||||
if (args.TryGetProperty("description", out var descEl))
|
||||
task.Description = (descEl.GetString() ?? "").Trim();
|
||||
|
||||
if (args.TryGetProperty("priority", out var priEl))
|
||||
{
|
||||
var priority = (priEl.GetString() ?? "").Trim().ToLowerInvariant();
|
||||
if (!TaskBoardStore.IsValidPriority(priority))
|
||||
return Task.FromResult(ToolResult.Fail("Invalid priority."));
|
||||
task.Priority = priority;
|
||||
}
|
||||
|
||||
task.UpdatedAt = DateTime.Now;
|
||||
TaskBoardStore.Save(context.WorkFolder, tasks);
|
||||
return Task.FromResult(ToolResult.Ok($"Updated task #{task.Id}: [{task.Status}] {task.Title}"));
|
||||
}
|
||||
}
|
||||
36
src/AxCopilot/Services/Agent/TeamCreateTool.cs
Normal file
36
src/AxCopilot/Services/Agent/TeamCreateTool.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class TeamCreateTool : IAgentTool
|
||||
{
|
||||
public string Name => "team_create";
|
||||
public string Description => "Create a teammate descriptor in local team board.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["name"] = new() { Type = "string", Description = "Teammate name." },
|
||||
["role"] = new() { Type = "string", Description = "Role (worker/explorer/reviewer...)." }
|
||||
},
|
||||
Required = ["name"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var name = args.TryGetProperty("name", out var n) ? (n.GetString() ?? "").Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Task.FromResult(ToolResult.Fail("name is required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var role = args.TryGetProperty("role", out var r) ? (r.GetString() ?? "worker").Trim() : "worker";
|
||||
var members = TeamStore.Load(context.WorkFolder);
|
||||
var member = new TeamStore.Member { Name = name, Role = role };
|
||||
members.Add(member);
|
||||
TeamStore.Save(context.WorkFolder, members);
|
||||
return Task.FromResult(ToolResult.Ok($"Created team member: {member.Name} ({member.Role}) id={member.Id}"));
|
||||
}
|
||||
}
|
||||
39
src/AxCopilot/Services/Agent/TeamDeleteTool.cs
Normal file
39
src/AxCopilot/Services/Agent/TeamDeleteTool.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class TeamDeleteTool : IAgentTool
|
||||
{
|
||||
public string Name => "team_delete";
|
||||
public string Description => "Delete a teammate by id or name from local team board.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["id"] = new() { Type = "string", Description = "Member id." },
|
||||
["name"] = new() { Type = "string", Description = "Member name (fallback)." }
|
||||
},
|
||||
Required = []
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var id = args.TryGetProperty("id", out var idEl) ? (idEl.GetString() ?? "").Trim() : "";
|
||||
var name = args.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "").Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(name))
|
||||
return Task.FromResult(ToolResult.Fail("id or name is required."));
|
||||
|
||||
var members = TeamStore.Load(context.WorkFolder);
|
||||
var removed = members.RemoveAll(m =>
|
||||
(!string.IsNullOrWhiteSpace(id) && string.Equals(m.Id, id, StringComparison.OrdinalIgnoreCase))
|
||||
|| (!string.IsNullOrWhiteSpace(name) && string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase)));
|
||||
TeamStore.Save(context.WorkFolder, members);
|
||||
return Task.FromResult(ToolResult.Ok(removed > 0 ? $"Deleted {removed} team member(s)." : "No matching member found."));
|
||||
}
|
||||
}
|
||||
52
src/AxCopilot/Services/Agent/TeamStore.cs
Normal file
52
src/AxCopilot/Services/Agent/TeamStore.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
internal static class TeamStore
|
||||
{
|
||||
private const string TeamRelativePath = ".ax/team.json";
|
||||
|
||||
internal sealed class Member
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; set; } = "worker";
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
|
||||
internal static string GetPath(string root) => Path.Combine(root, TeamRelativePath);
|
||||
|
||||
internal static List<Member> Load(string root)
|
||||
{
|
||||
var path = GetPath(root);
|
||||
if (!File.Exists(path))
|
||||
return [];
|
||||
try
|
||||
{
|
||||
var json = TextFileCodec.ReadAllText(path).Text;
|
||||
return JsonSerializer.Deserialize<List<Member>>(json) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
internal static void Save(string root, List<Member> members)
|
||||
{
|
||||
var path = GetPath(root);
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(members, new JsonSerializerOptions { WriteIndented = true }), TextFileCodec.Utf8NoBom);
|
||||
}
|
||||
}
|
||||
113
src/AxCopilot/Services/Agent/TodoWriteTool.cs
Normal file
113
src/AxCopilot/Services/Agent/TodoWriteTool.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class TodoWriteTool : IAgentTool
|
||||
{
|
||||
public string Name => "todo_write";
|
||||
|
||||
public string Description =>
|
||||
"Maintain a lightweight TODO markdown list (.ax/TODO.md). " +
|
||||
"Actions: add, list, done.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "add | list | done",
|
||||
Enum = ["add", "list", "done"]
|
||||
},
|
||||
["text"] = new() { Type = "string", Description = "Todo text (for add)." },
|
||||
["index"] = new() { Type = "integer", Description = "Todo index from list (for done)." }
|
||||
},
|
||||
Required = ["action"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var actionEl) ? (actionEl.GetString() ?? "").Trim().ToLowerInvariant() : "";
|
||||
if (string.IsNullOrWhiteSpace(action))
|
||||
return Task.FromResult(ToolResult.Fail("action is required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var todoPath = Path.Combine(context.WorkFolder, ".ax", "TODO.md");
|
||||
return action switch
|
||||
{
|
||||
"add" => Task.FromResult(Add(todoPath, args)),
|
||||
"list" => Task.FromResult(List(todoPath)),
|
||||
"done" => Task.FromResult(Done(todoPath, args)),
|
||||
_ => Task.FromResult(ToolResult.Fail("Unsupported action. Use add|list|done."))
|
||||
};
|
||||
}
|
||||
|
||||
private static ToolResult Add(string todoPath, JsonElement args)
|
||||
{
|
||||
var text = args.TryGetProperty("text", out var textEl) ? (textEl.GetString() ?? "").Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return ToolResult.Fail("text is required for add.");
|
||||
|
||||
var dir = Path.GetDirectoryName(todoPath);
|
||||
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
if (!File.Exists(todoPath))
|
||||
File.WriteAllText(todoPath, "# TODO\n\n", TextFileCodec.Utf8NoBom);
|
||||
|
||||
File.AppendAllText(todoPath, $"- [ ] {text}\n", TextFileCodec.Utf8NoBom);
|
||||
return ToolResult.Ok($"Added TODO: {text}", todoPath);
|
||||
}
|
||||
|
||||
private static ToolResult List(string todoPath)
|
||||
{
|
||||
if (!File.Exists(todoPath))
|
||||
return ToolResult.Ok("TODO list is empty.");
|
||||
|
||||
var lines = TextFileCodec.SplitLines(TextFileCodec.ReadAllText(todoPath).Text);
|
||||
var todos = lines
|
||||
.Where(l => l.TrimStart().StartsWith("- [", StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
if (todos.Count == 0)
|
||||
return ToolResult.Ok("TODO list is empty.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < todos.Count; i++)
|
||||
sb.AppendLine($"[{i}] {todos[i].Trim()}");
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static ToolResult Done(string todoPath, JsonElement args)
|
||||
{
|
||||
if (!File.Exists(todoPath))
|
||||
return ToolResult.Fail("TODO file does not exist.");
|
||||
if (!args.TryGetProperty("index", out var indexEl))
|
||||
return ToolResult.Fail("index is required for done.");
|
||||
|
||||
var targetIndex = indexEl.GetInt32();
|
||||
var lines = TextFileCodec.SplitLines(TextFileCodec.ReadAllText(todoPath).Text);
|
||||
var todoLineIndexes = new List<int>();
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (lines[i].TrimStart().StartsWith("- [", StringComparison.Ordinal))
|
||||
todoLineIndexes.Add(i);
|
||||
}
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= todoLineIndexes.Count)
|
||||
return ToolResult.Fail("invalid index.");
|
||||
|
||||
var lineIndex = todoLineIndexes[targetIndex];
|
||||
var current = lines[lineIndex];
|
||||
if (current.Contains("- [x]", StringComparison.OrdinalIgnoreCase))
|
||||
return ToolResult.Ok($"TODO[{targetIndex}] already done.");
|
||||
|
||||
lines[lineIndex] = current.Replace("- [ ]", "- [x]").Replace("- [X]", "- [x]");
|
||||
File.WriteAllText(todoPath, string.Join(Environment.NewLine, lines), TextFileCodec.Utf8NoBom);
|
||||
return ToolResult.Ok($"Marked TODO[{targetIndex}] as done.", todoPath);
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,7 @@ public class ToolRegistry : IDisposable
|
||||
registry.Register(new WaitAgentsTool());
|
||||
registry.Register(new CodeSearchTool());
|
||||
registry.Register(new TestLoopTool());
|
||||
registry.Register(new ToolSearchTool(() => registry.All));
|
||||
|
||||
// 코드 리뷰 + 프로젝트 규칙
|
||||
registry.Register(new CodeReviewTool());
|
||||
@@ -179,6 +180,22 @@ public class ToolRegistry : IDisposable
|
||||
|
||||
// 태스크 추적
|
||||
registry.Register(new TaskTrackerTool());
|
||||
registry.Register(new TodoWriteTool());
|
||||
registry.Register(new TaskCreateTool());
|
||||
registry.Register(new TaskGetTool());
|
||||
registry.Register(new TaskListTool());
|
||||
registry.Register(new TaskUpdateTool());
|
||||
registry.Register(new TaskStopTool());
|
||||
registry.Register(new TaskOutputTool());
|
||||
registry.Register(new EnterPlanModeTool());
|
||||
registry.Register(new ExitPlanModeTool());
|
||||
registry.Register(new EnterWorktreeTool());
|
||||
registry.Register(new ExitWorktreeTool());
|
||||
registry.Register(new TeamCreateTool());
|
||||
registry.Register(new TeamDeleteTool());
|
||||
registry.Register(new CronCreateTool());
|
||||
registry.Register(new CronDeleteTool());
|
||||
registry.Register(new CronListTool());
|
||||
|
||||
// 워크플로우 도구
|
||||
registry.Register(new SuggestActionsTool());
|
||||
|
||||
92
src/AxCopilot/Services/Agent/ToolSearchTool.cs
Normal file
92
src/AxCopilot/Services/Agent/ToolSearchTool.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public sealed class ToolSearchTool : IAgentTool
|
||||
{
|
||||
private readonly Func<IReadOnlyCollection<IAgentTool>> _toolProvider;
|
||||
|
||||
public ToolSearchTool(Func<IReadOnlyCollection<IAgentTool>> toolProvider)
|
||||
{
|
||||
_toolProvider = toolProvider;
|
||||
}
|
||||
|
||||
public string Name => "tool_search";
|
||||
|
||||
public string Description =>
|
||||
"Search available tools by name or description and return ranked matches.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["query"] = new() { Type = "string", Description = "Search query." },
|
||||
["limit"] = new() { Type = "integer", Description = "Max results (default 10, max 30)." },
|
||||
["include_description"] = new() { Type = "boolean", Description = "Include descriptions in output." }
|
||||
},
|
||||
Required = ["query"]
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var query = args.TryGetProperty("query", out var queryEl) ? (queryEl.GetString() ?? "").Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return Task.FromResult(ToolResult.Fail("query is required."));
|
||||
|
||||
var limit = args.TryGetProperty("limit", out var limitEl) ? Math.Clamp(limitEl.GetInt32(), 1, 30) : 10;
|
||||
var includeDescription = args.TryGetProperty("include_description", out var descEl)
|
||||
&& descEl.ValueKind == JsonValueKind.True;
|
||||
|
||||
var queryTokens = query.Split([' ', '-', '_', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(t => t.ToLowerInvariant())
|
||||
.ToArray();
|
||||
|
||||
var ranked = _toolProvider()
|
||||
.Select(t => new { Tool = t, Score = Score(t, query.ToLowerInvariant(), queryTokens) })
|
||||
.Where(x => x.Score > 0)
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ThenBy(x => x.Tool.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
if (ranked.Count == 0)
|
||||
return Task.FromResult(ToolResult.Ok("No matching tools."));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"tool matches: {ranked.Count}");
|
||||
foreach (var item in ranked)
|
||||
{
|
||||
if (includeDescription)
|
||||
sb.AppendLine($"- {item.Tool.Name}: {item.Tool.Description}");
|
||||
else
|
||||
sb.AppendLine($"- {item.Tool.Name}");
|
||||
}
|
||||
|
||||
return Task.FromResult(ToolResult.Ok(sb.ToString()));
|
||||
}
|
||||
|
||||
private static int Score(IAgentTool tool, string query, IReadOnlyCollection<string> queryTokens)
|
||||
{
|
||||
var name = tool.Name.ToLowerInvariant();
|
||||
var desc = tool.Description.ToLowerInvariant();
|
||||
|
||||
var score = 0;
|
||||
if (name == query)
|
||||
score += 1000;
|
||||
if (name.Contains(query, StringComparison.Ordinal))
|
||||
score += 300;
|
||||
if (desc.Contains(query, StringComparison.Ordinal))
|
||||
score += 100;
|
||||
|
||||
foreach (var token in queryTokens)
|
||||
{
|
||||
if (name.Contains(token, StringComparison.Ordinal))
|
||||
score += 120;
|
||||
if (desc.Contains(token, StringComparison.Ordinal))
|
||||
score += 35;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
}
|
||||
85
src/AxCopilot/Services/Agent/WorktreeStateStore.cs
Normal file
85
src/AxCopilot/Services/Agent/WorktreeStateStore.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
internal static class WorktreeStateStore
|
||||
{
|
||||
private const string StateRelativePath = ".ax/worktree_state.json";
|
||||
|
||||
internal sealed class WorktreeState
|
||||
{
|
||||
[JsonPropertyName("root")]
|
||||
public string Root { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("active")]
|
||||
public string Active { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
|
||||
private static string GetPath(string root) => Path.Combine(root, StateRelativePath);
|
||||
|
||||
internal static string ResolveRoot(string current)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = new DirectoryInfo(Path.GetFullPath(current));
|
||||
while (dir != null)
|
||||
{
|
||||
var candidate = dir.FullName;
|
||||
var statePath = GetPath(candidate);
|
||||
if (File.Exists(statePath))
|
||||
{
|
||||
var state = Load(candidate);
|
||||
if (!string.IsNullOrWhiteSpace(state.Root))
|
||||
{
|
||||
var resolvedRoot = Path.GetFullPath(state.Root);
|
||||
if (Directory.Exists(resolvedRoot))
|
||||
return resolvedRoot;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
dir = dir.Parent;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore and fallback
|
||||
}
|
||||
|
||||
return Path.GetFullPath(current);
|
||||
}
|
||||
|
||||
internal static WorktreeState Load(string root)
|
||||
{
|
||||
var path = GetPath(root);
|
||||
if (!File.Exists(path))
|
||||
return new WorktreeState { Root = root, Active = root, UpdatedAt = DateTime.Now };
|
||||
|
||||
try
|
||||
{
|
||||
var json = TextFileCodec.ReadAllText(path).Text;
|
||||
var state = JsonSerializer.Deserialize<WorktreeState>(json);
|
||||
return state ?? new WorktreeState { Root = root, Active = root, UpdatedAt = DateTime.Now };
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new WorktreeState { Root = root, Active = root, UpdatedAt = DateTime.Now };
|
||||
}
|
||||
}
|
||||
|
||||
internal static void Save(string root, WorktreeState state)
|
||||
{
|
||||
var path = GetPath(root);
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
state.Root = root;
|
||||
state.UpdatedAt = DateTime.Now;
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(state, new JsonSerializerOptions { WriteIndented = true }), TextFileCodec.Utf8NoBom);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user