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:
@@ -11,6 +11,7 @@
|
|||||||
- 기능/로직 구현은 `claude-code` 실제 코드 흐름을 우선 참고하여 동등 품질 기준으로 반영합니다. (실제 폴더: `claw-code`)
|
- 기능/로직 구현은 `claude-code` 실제 코드 흐름을 우선 참고하여 동등 품질 기준으로 반영합니다. (실제 폴더: `claw-code`)
|
||||||
- `claude-code`와 추후 비교/대조 시 문제 없도록 **기본 로직(동작 순서·예외 처리·검증 흐름)은 유지**하되, 코드 표현(변수명·함수 분해·구조화)은 가독성과 제품 코드 규칙에 맞게 변경하여 반영합니다.
|
- `claude-code`와 추후 비교/대조 시 문제 없도록 **기본 로직(동작 순서·예외 처리·검증 흐름)은 유지**하되, 코드 표현(변수명·함수 분해·구조화)은 가독성과 제품 코드 규칙에 맞게 변경하여 반영합니다.
|
||||||
- 작업 완료 후에는 변경사항을 점검하고 **반드시 Git push까지 진행**합니다.
|
- 작업 완료 후에는 변경사항을 점검하고 **반드시 Git push까지 진행**합니다.
|
||||||
|
- Git 커밋/푸시 시 커밋 메시지는 **반드시 한국어로 작성**하며, 변경 목적·핵심 수정사항·검증 결과가 드러나도록 **상세하게** 작성합니다.
|
||||||
- 작업 중 오류가 발생해 복구가 되지 않으면, **이전 정상 버전을 다시 받아 기준 상태에서 작업을 재개**합니다.
|
- 작업 중 오류가 발생해 복구가 되지 않으면, **이전 정상 버전을 다시 받아 기준 상태에서 작업을 재개**합니다.
|
||||||
|
|
||||||
### 개발 계획 수립 기준 (필수)
|
### 개발 계획 수립 기준 (필수)
|
||||||
|
|||||||
@@ -34,9 +34,9 @@
|
|||||||
3. 패리티 수치(테스트 통과 수/게이트 상태)를 로드맵 문서 간 동일 문구로 유지.
|
3. 패리티 수치(테스트 통과 수/게이트 상태)를 로드맵 문서 간 동일 문구로 유지.
|
||||||
|
|
||||||
## 6. 최신 검증 스냅샷 (2026-04-03)
|
## 6. 최신 검증 스냅샷 (2026-04-03)
|
||||||
- `dotnet test --filter "Suite=ParityBenchmark"`: 7/7 통과.
|
- `dotnet test --filter "Suite=ParityBenchmark"`: 11/11 통과.
|
||||||
- `dotnet test --filter "Suite=ReplayStability"`: 12/12 통과.
|
- `dotnet test --filter "Suite=ReplayStability"`: 12/12 통과.
|
||||||
- `dotnet test`: 361/361 통과.
|
- `dotnet test`: 371/371 통과.
|
||||||
|
|
||||||
## 7. 권한 Hook 계약 (P2 마감 기준)
|
## 7. 권한 Hook 계약 (P2 마감 기준)
|
||||||
- lifecycle hook 키:
|
- lifecycle hook 키:
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
## 6. 2026-04-03 점검 스냅샷
|
## 6. 2026-04-03 점검 스냅샷
|
||||||
- 기준 시점: 2026-04-03.
|
- 기준 시점: 2026-04-03.
|
||||||
- 계획 대비 현재 수준: 약 92~95%.
|
- 계획 대비 현재 수준: 약 92~95%.
|
||||||
- 테스트 상태: `dotnet test` 361/361 통과.
|
- 테스트 상태: `dotnet test` 371/371 통과.
|
||||||
- P1 Hook 계약: 구현 완료 수준.
|
- P1 Hook 계약: 구현 완료 수준.
|
||||||
- P2 세션/이벤트 내구성: 구현 완료 수준(복원/재생 경계 케이스 테스트 반영).
|
- P2 세션/이벤트 내구성: 구현 완료 수준(복원/재생 경계 케이스 테스트 반영).
|
||||||
- P3 실패 복구 표준화: 구현 완료 수준(unknown-tool/권한/정체/fork 강제 흐름 반영).
|
- P3 실패 복구 표준화: 구현 완료 수준(unknown-tool/권한/정체/fork 강제 흐름 반영).
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
- 레거시 도구명 `process_run` 참조: 0건 (`process`로 정규화).
|
- 레거시 도구명 `process_run` 참조: 0건 (`process`로 정규화).
|
||||||
- 레거시 도구명 `grep_tool` 참조: 0건 (`grep`로 정규화).
|
- 레거시 도구명 `grep_tool` 참조: 0건 (`grep`로 정규화).
|
||||||
- 내부 모드 차단 정책: `http_tool` 전면 차단, `open_external`의 외부 URL 차단.
|
- 내부 모드 차단 정책: `http_tool` 전면 차단, `open_external`의 외부 URL 차단.
|
||||||
- 테스트 상태: `dotnet test` 361/361 통과.
|
- 테스트 상태: `dotnet test` 371/371 통과.
|
||||||
|
|
||||||
## 8. claw-code 소스 직접 비교 결과 (2026-04-03)
|
## 8. claw-code 소스 직접 비교 결과 (2026-04-03)
|
||||||
- 비교 기준 소스: `claw-code/.../src/tools.ts`, `src/Tool.ts`, `src/skills/loadSkillsDir.ts`, `src/skills/bundled/*.ts`.
|
- 비교 기준 소스: `claw-code/.../src/tools.ts`, `src/Tool.ts`, `src/skills/loadSkillsDir.ts`, `src/skills/bundled/*.ts`.
|
||||||
@@ -101,16 +101,17 @@
|
|||||||
| Hook 입력 변형 반영 | `AgentLoopE2ETests.RunAsync_PreHookInputMutation_ChangesToolArguments` | pre-hook `updatedInput`이 실제 도구 입력에 적용됨 |
|
| Hook 입력 변형 반영 | `AgentLoopE2ETests.RunAsync_PreHookInputMutation_ChangesToolArguments` | pre-hook `updatedInput`이 실제 도구 입력에 적용됨 |
|
||||||
| Runtime 정책(`allowed_tools`) 강제 | `AgentLoopE2ETests.RunAsync_DisallowedTool_ByRuntimePolicy_EmitsPolicyRecoveryError` | 비허용 도구 차단 + 정책 복구 경고 후 종료 |
|
| Runtime 정책(`allowed_tools`) 강제 | `AgentLoopE2ETests.RunAsync_DisallowedTool_ByRuntimePolicy_EmitsPolicyRecoveryError` | 비허용 도구 차단 + 정책 복구 경고 후 종료 |
|
||||||
| Hook filter 정합성 | `AgentLoopE2ETests.RunAsync_HookFilters_ExecuteOnlyMatchingHookForToolAndTiming` | 지정된 hook만 실행되고 비매칭 hook는 미실행 |
|
| Hook filter 정합성 | `AgentLoopE2ETests.RunAsync_HookFilters_ExecuteOnlyMatchingHookForToolAndTiming` | 지정된 hook만 실행되고 비매칭 hook는 미실행 |
|
||||||
|
| claw-code alias(`EnterPlanMode`) 정규화 | `AgentLoopE2ETests.RunAsync_EnterPlanModeAlias_ResolvesAndExecutes` | CamelCase 도구명이 AX 내부 snake_case 도구로 매핑되어 정상 실행 |
|
||||||
|
|
||||||
### 벤치마크 배포 체크리스트 연결
|
### 벤치마크 배포 체크리스트 연결
|
||||||
1. `dotnet build` 경고 0/오류 0.
|
1. `dotnet build` 경고 0/오류 0.
|
||||||
2. `dotnet test` 전체 통과 (`361/361` 기준, 증가 시 최신 값으로 동기화).
|
2. `dotnet test` 전체 통과 (`371/371` 기준, 증가 시 최신 값으로 동기화).
|
||||||
3. 위 7개 시나리오의 회귀 테스트가 모두 통과.
|
3. 위 8개 시나리오의 회귀 테스트가 모두 통과.
|
||||||
4. 패리티 수치/상태를 `NEXT_ROADMAP.md`와 동일 문구로 동기화.
|
4. 패리티 수치/상태를 `NEXT_ROADMAP.md`와 동일 문구로 동기화.
|
||||||
5. 릴리즈 전 게이트 스크립트 실행: `powershell -ExecutionPolicy Bypass -File .\scripts\release-gate.ps1`
|
5. 릴리즈 전 게이트 스크립트 실행: `powershell -ExecutionPolicy Bypass -File .\scripts\release-gate.ps1`
|
||||||
|
|
||||||
### 실행 증적 (2026-04-03)
|
### 실행 증적 (2026-04-03)
|
||||||
- `dotnet test --filter "Suite=ParityBenchmark"`: 7/7 통과.
|
- `dotnet test --filter "Suite=ParityBenchmark"`: 11/11 통과.
|
||||||
- `powershell -ExecutionPolicy Bypass -File .\scripts\release-gate.ps1`: build/replay/full gate 통과.
|
- `powershell -ExecutionPolicy Bypass -File .\scripts\release-gate.ps1`: build/replay/full gate 통과.
|
||||||
|
|
||||||
## 13. 세션 Replay 안정성 기준 (고정)
|
## 13. 세션 Replay 안정성 기준 (고정)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
## 7. 2026-04-03 실행 증적 동기화 (M4 포함)
|
## 7. 2026-04-03 실행 증적 동기화 (M4 포함)
|
||||||
- 기준 시점: 2026-04-03.
|
- 기준 시점: 2026-04-03.
|
||||||
- 테스트: `dotnet test` 361/361 통과.
|
- 테스트: `dotnet test` 371/371 통과.
|
||||||
- M1 증적: Hook 계약 필드(`updatedInput`, `updatedPermissions`, `additionalContext`) 반영 경로 구현 완료.
|
- M1 증적: Hook 계약 필드(`updatedInput`, `updatedPermissions`, `additionalContext`) 반영 경로 구현 완료.
|
||||||
- M2 증적: run 복원/이력 재구성(`RestoreRecentFromExecutionEvents`, `RestoreCurrentAgentRun`, plan 이력 조회) 구현 및 테스트 존재.
|
- M2 증적: run 복원/이력 재구성(`RestoreRecentFromExecutionEvents`, `RestoreCurrentAgentRun`, plan 이력 조회) 구현 및 테스트 존재.
|
||||||
- M3 증적: unknown-tool 복구 루프/결정 이벤트 처리 경로 구현 및 테스트 존재.
|
- M3 증적: unknown-tool 복구 루프/결정 이벤트 처리 경로 구현 및 테스트 존재.
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
- 기준 문서: `docs/CLAW_CODE_PARITY_PLAN.md` 13절.
|
- 기준 문서: `docs/CLAW_CODE_PARITY_PLAN.md` 13절.
|
||||||
- 테스트 태그: `Suite=ReplayStability`.
|
- 테스트 태그: `Suite=ReplayStability`.
|
||||||
- 운영 기준: 릴리즈 전 `ReplayStability` 시나리오 전건 통과 시 replay 불일치 0건으로 판정.
|
- 운영 기준: 릴리즈 전 `ReplayStability` 시나리오 전건 통과 시 replay 불일치 0건으로 판정.
|
||||||
- 최신 실행 증적(2026-04-03): `ParityBenchmark 7/7`, `ReplayStability 12/12`, 전체 `361/361`.
|
- 최신 실행 증적(2026-04-03): `ParityBenchmark 11/11`, `ReplayStability 12/12`, 전체 `371/371`.
|
||||||
- 실행 자동화: `scripts/release-gate.ps1`로 빌드/벤치마크/리플레이/전체 테스트를 일괄 점검.
|
- 실행 자동화: `scripts/release-gate.ps1`로 빌드/벤치마크/리플레이/전체 테스트를 일괄 점검.
|
||||||
|
|
||||||
## 11. 권한 Hook 계약 고정 (M1 완료 기준)
|
## 11. 권한 Hook 계약 고정 (M1 완료 기준)
|
||||||
|
|||||||
@@ -309,6 +309,170 @@ public class AgentLoopE2ETests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_TaskCreateAlias_ResolvesAndExecutes()
|
||||||
|
{
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-task-alias-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var server = new FakeOllamaServer(
|
||||||
|
[
|
||||||
|
BuildToolCallResponse("TaskCreate", new { title = "Alias task", priority = "high" }, "task alias"),
|
||||||
|
BuildTextResponse("완료"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
var settings = BuildLoopSettings(server.Endpoint);
|
||||||
|
settings.Settings.Llm.WorkFolder = tempDir;
|
||||||
|
using var llm = new LlmService(settings);
|
||||||
|
using var tools = ToolRegistry.CreateDefault();
|
||||||
|
var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" };
|
||||||
|
|
||||||
|
var events = new List<AgentEvent>();
|
||||||
|
loop.EventOccurred += evt => events.Add(evt);
|
||||||
|
|
||||||
|
var result = await loop.RunAsync(
|
||||||
|
[
|
||||||
|
new ChatMessage { Role = "user", Content = "task create alias" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
result.Should().Contain("완료");
|
||||||
|
events.Should().Contain(e => e.Type == AgentEventType.ToolCall && e.ToolName == "task_create");
|
||||||
|
events.Should().Contain(e => e.Type == AgentEventType.ToolResult && e.ToolName == "task_create" && e.Success);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_EnterPlanModeAlias_ResolvesAndExecutes()
|
||||||
|
{
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-plan-alias-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var server = new FakeOllamaServer(
|
||||||
|
[
|
||||||
|
BuildToolCallResponse("EnterPlanMode", new { }, "plan alias"),
|
||||||
|
BuildTextResponse("?꾨즺"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
var settings = BuildLoopSettings(server.Endpoint);
|
||||||
|
settings.Settings.Llm.WorkFolder = tempDir;
|
||||||
|
using var llm = new LlmService(settings);
|
||||||
|
using var tools = ToolRegistry.CreateDefault();
|
||||||
|
var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" };
|
||||||
|
|
||||||
|
var events = new List<AgentEvent>();
|
||||||
|
loop.EventOccurred += evt => events.Add(evt);
|
||||||
|
|
||||||
|
var result = await loop.RunAsync(
|
||||||
|
[
|
||||||
|
new ChatMessage { Role = "user", Content = "enter plan mode alias" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
result.Should().Contain("?꾨즺");
|
||||||
|
events.Should().Contain(e => e.Type == AgentEventType.ToolCall && e.ToolName == "enter_plan_mode");
|
||||||
|
events.Should().Contain(e => e.Type == AgentEventType.ToolResult && e.ToolName == "enter_plan_mode" && e.Success);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_WorktreeState_PersistsAcrossRuns()
|
||||||
|
{
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-worktree-persist-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var enterServer = new FakeOllamaServer(
|
||||||
|
[
|
||||||
|
BuildToolCallResponse("EnterWorktree", new { path = "wt1", create = true }, "enter worktree"),
|
||||||
|
BuildTextResponse("done-1"),
|
||||||
|
]))
|
||||||
|
{
|
||||||
|
var enterSettings = BuildLoopSettings(enterServer.Endpoint);
|
||||||
|
enterSettings.Settings.Llm.WorkFolder = tempDir;
|
||||||
|
enterSettings.Settings.Llm.FilePermission = "Auto";
|
||||||
|
using var enterLlm = new LlmService(enterSettings);
|
||||||
|
using var enterTools = ToolRegistry.CreateDefault();
|
||||||
|
var enterLoop = new AgentLoopService(enterLlm, enterTools, enterSettings) { ActiveTab = "Code" };
|
||||||
|
var first = await enterLoop.RunAsync(
|
||||||
|
[
|
||||||
|
new ChatMessage { Role = "user", Content = "enter worktree now" }
|
||||||
|
]);
|
||||||
|
first.Should().Contain("done-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var writeServer = new FakeOllamaServer(
|
||||||
|
[
|
||||||
|
BuildToolCallResponse("TaskCreate", new { title = "persisted-task", priority = "high" }, "create task in persisted worktree"),
|
||||||
|
BuildTextResponse("done-2"),
|
||||||
|
]))
|
||||||
|
{
|
||||||
|
var writeSettings = BuildLoopSettings(writeServer.Endpoint);
|
||||||
|
writeSettings.Settings.Llm.WorkFolder = tempDir;
|
||||||
|
writeSettings.Settings.Llm.FilePermission = "Auto";
|
||||||
|
using var writeLlm = new LlmService(writeSettings);
|
||||||
|
using var writeTools = ToolRegistry.CreateDefault();
|
||||||
|
var writeLoop = new AgentLoopService(writeLlm, writeTools, writeSettings) { ActiveTab = "Code" };
|
||||||
|
var second = await writeLoop.RunAsync(
|
||||||
|
[
|
||||||
|
new ChatMessage { Role = "user", Content = "write file in active worktree" }
|
||||||
|
]);
|
||||||
|
second.Should().Contain("done-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Exists(Path.Combine(tempDir, "wt1", ".ax", "taskboard.json")).Should().BeTrue();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_CodeSetting_DisablesPlanModeTools()
|
||||||
|
{
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-disable-plan-tool-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var server = new FakeOllamaServer(
|
||||||
|
[
|
||||||
|
BuildToolCallResponse("EnterPlanMode", new { }, "attempt disabled tool"),
|
||||||
|
BuildTextResponse("done"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
var settings = BuildLoopSettings(server.Endpoint);
|
||||||
|
settings.Settings.Llm.WorkFolder = tempDir;
|
||||||
|
settings.Settings.Llm.Code.EnablePlanModeTools = false;
|
||||||
|
using var llm = new LlmService(settings);
|
||||||
|
using var tools = ToolRegistry.CreateDefault();
|
||||||
|
var loop = new AgentLoopService(llm, tools, settings) { ActiveTab = "Code" };
|
||||||
|
|
||||||
|
var events = new List<AgentEvent>();
|
||||||
|
loop.EventOccurred += evt => events.Add(evt);
|
||||||
|
|
||||||
|
var result = await loop.RunAsync(
|
||||||
|
[
|
||||||
|
new ChatMessage { Role = "user", Content = "run plan mode tool" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
result.Should().Contain("done");
|
||||||
|
events.Should().Contain(e => e.Type == AgentEventType.Error && e.ToolName == "EnterPlanMode");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static SettingsService BuildLoopSettings(string endpoint)
|
private static SettingsService BuildLoopSettings(string endpoint)
|
||||||
{
|
{
|
||||||
var settings = new SettingsService();
|
var settings = new SettingsService();
|
||||||
|
|||||||
167
src/AxCopilot.Tests/Services/AgentParityToolsTests.cs
Normal file
167
src/AxCopilot.Tests/Services/AgentParityToolsTests.cs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using AxCopilot.Services.Agent;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AxCopilot.Tests.Services;
|
||||||
|
|
||||||
|
public class AgentParityToolsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task TaskBoardTools_CreateUpdateOutputAndGet_ShouldRoundTrip()
|
||||||
|
{
|
||||||
|
var workDir = Path.Combine(Path.GetTempPath(), "ax-task-board-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(workDir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = new AgentContext
|
||||||
|
{
|
||||||
|
WorkFolder = workDir,
|
||||||
|
Permission = "Auto",
|
||||||
|
OperationMode = "external",
|
||||||
|
};
|
||||||
|
|
||||||
|
var create = new TaskCreateTool();
|
||||||
|
var update = new TaskUpdateTool();
|
||||||
|
var output = new TaskOutputTool();
|
||||||
|
var get = new TaskGetTool();
|
||||||
|
|
||||||
|
var created = await create.ExecuteAsync(JsonDocument.Parse("""{"title":"Implement parity","priority":"high"}""").RootElement, context);
|
||||||
|
created.Success.Should().BeTrue();
|
||||||
|
created.Output.Should().Contain("#1");
|
||||||
|
|
||||||
|
var updated = await update.ExecuteAsync(JsonDocument.Parse("""{"id":1,"status":"in_progress"}""").RootElement, context);
|
||||||
|
updated.Success.Should().BeTrue();
|
||||||
|
updated.Output.Should().Contain("in_progress");
|
||||||
|
|
||||||
|
var outResult = await output.ExecuteAsync(JsonDocument.Parse("""{"id":1,"output":"step-1 done","append":true}""").RootElement, context);
|
||||||
|
outResult.Success.Should().BeTrue();
|
||||||
|
|
||||||
|
var loaded = await get.ExecuteAsync(JsonDocument.Parse("""{"id":1}""").RootElement, context);
|
||||||
|
loaded.Success.Should().BeTrue();
|
||||||
|
loaded.Output.Should().Contain("Implement parity");
|
||||||
|
loaded.Output.Should().Contain("step-1 done");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { if (Directory.Exists(workDir)) Directory.Delete(workDir, true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TodoWriteTool_AddListDone_ShouldTrackMarkdownChecklist()
|
||||||
|
{
|
||||||
|
var workDir = Path.Combine(Path.GetTempPath(), "ax-todo-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(workDir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = new AgentContext
|
||||||
|
{
|
||||||
|
WorkFolder = workDir,
|
||||||
|
Permission = "Auto",
|
||||||
|
OperationMode = "external",
|
||||||
|
};
|
||||||
|
|
||||||
|
var tool = new TodoWriteTool();
|
||||||
|
(await tool.ExecuteAsync(JsonDocument.Parse("""{"action":"add","text":"Check permissions"}""").RootElement, context)).Success.Should().BeTrue();
|
||||||
|
var list = await tool.ExecuteAsync(JsonDocument.Parse("""{"action":"list"}""").RootElement, context);
|
||||||
|
list.Success.Should().BeTrue();
|
||||||
|
list.Output.Should().Contain("Check permissions");
|
||||||
|
|
||||||
|
var done = await tool.ExecuteAsync(JsonDocument.Parse("""{"action":"done","index":0}""").RootElement, context);
|
||||||
|
done.Success.Should().BeTrue();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { if (Directory.Exists(workDir)) Directory.Delete(workDir, true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PlanModeTools_EnterThenExit_ShouldToggleStateFile()
|
||||||
|
{
|
||||||
|
var workDir = Path.Combine(Path.GetTempPath(), "ax-plan-mode-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(workDir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = new AgentContext
|
||||||
|
{
|
||||||
|
WorkFolder = workDir,
|
||||||
|
Permission = "Auto",
|
||||||
|
OperationMode = "external",
|
||||||
|
};
|
||||||
|
|
||||||
|
var enter = new EnterPlanModeTool();
|
||||||
|
var exit = new ExitPlanModeTool();
|
||||||
|
var statePath = Path.Combine(workDir, ".ax", "plan_mode.state");
|
||||||
|
|
||||||
|
(await enter.ExecuteAsync(JsonDocument.Parse("""{}""").RootElement, context)).Success.Should().BeTrue();
|
||||||
|
File.Exists(statePath).Should().BeTrue();
|
||||||
|
|
||||||
|
(await exit.ExecuteAsync(JsonDocument.Parse("""{}""").RootElement, context)).Success.Should().BeTrue();
|
||||||
|
File.Exists(statePath).Should().BeFalse();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { if (Directory.Exists(workDir)) Directory.Delete(workDir, true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WorktreeTeamCronTools_ShouldPersistState()
|
||||||
|
{
|
||||||
|
var workDir = Path.Combine(Path.GetTempPath(), "ax-worktree-team-cron-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(workDir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = new AgentContext
|
||||||
|
{
|
||||||
|
WorkFolder = workDir,
|
||||||
|
Permission = "Auto",
|
||||||
|
OperationMode = "external",
|
||||||
|
};
|
||||||
|
|
||||||
|
var enterWorktree = new EnterWorktreeTool();
|
||||||
|
var exitWorktree = new ExitWorktreeTool();
|
||||||
|
var entered = await enterWorktree.ExecuteAsync(JsonDocument.Parse("""{"path":"feature/a","create":true}""").RootElement, context);
|
||||||
|
entered.Success.Should().BeTrue();
|
||||||
|
File.Exists(Path.Combine(workDir, ".ax", "worktree_state.json")).Should().BeTrue();
|
||||||
|
context.WorkFolder.Replace('\\', '/').Should().EndWith("/feature/a");
|
||||||
|
|
||||||
|
var write = new FileWriteTool();
|
||||||
|
var writeInWorktree = await write.ExecuteAsync(
|
||||||
|
JsonDocument.Parse("""{"path":"notes.txt","content":"worktree-content"}""").RootElement,
|
||||||
|
context,
|
||||||
|
CancellationToken.None);
|
||||||
|
writeInWorktree.Success.Should().BeTrue();
|
||||||
|
File.Exists(Path.Combine(workDir, "feature", "a", "notes.txt")).Should().BeTrue();
|
||||||
|
|
||||||
|
var exited = await exitWorktree.ExecuteAsync(JsonDocument.Parse("""{}""").RootElement, context);
|
||||||
|
exited.Success.Should().BeTrue();
|
||||||
|
Path.GetFullPath(context.WorkFolder).Should().Be(Path.GetFullPath(workDir));
|
||||||
|
|
||||||
|
var teamCreate = new TeamCreateTool();
|
||||||
|
var teamDelete = new TeamDeleteTool();
|
||||||
|
(await teamCreate.ExecuteAsync(JsonDocument.Parse("""{"name":"worker-1","role":"worker"}""").RootElement, context)).Success.Should().BeTrue();
|
||||||
|
var teamFile = Path.Combine(workDir, ".ax", "team.json");
|
||||||
|
File.Exists(teamFile).Should().BeTrue();
|
||||||
|
var teamJson = await File.ReadAllTextAsync(teamFile);
|
||||||
|
teamJson.Should().Contain("worker-1");
|
||||||
|
(await teamDelete.ExecuteAsync(JsonDocument.Parse("""{"name":"worker-1"}""").RootElement, context)).Success.Should().BeTrue();
|
||||||
|
|
||||||
|
var cronCreate = new CronCreateTool();
|
||||||
|
var cronList = new CronListTool();
|
||||||
|
var cronDelete = new CronDeleteTool();
|
||||||
|
(await cronCreate.ExecuteAsync(JsonDocument.Parse("""{"name":"nightly","schedule":"daily 02:00","command":"build"}""").RootElement, context)).Success.Should().BeTrue();
|
||||||
|
var listed = await cronList.ExecuteAsync(JsonDocument.Parse("""{}""").RootElement, context);
|
||||||
|
listed.Success.Should().BeTrue();
|
||||||
|
listed.Output.Should().Contain("nightly");
|
||||||
|
(await cronDelete.ExecuteAsync(JsonDocument.Parse("""{"name":"nightly"}""").RootElement, context)).Success.Should().BeTrue();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { if (Directory.Exists(workDir)) Directory.Delete(workDir, true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,4 +59,47 @@ public class OperationModePolicyTests
|
|||||||
var allowed = await context.CheckToolPermissionAsync("http_tool", "https://example.com");
|
var allowed = await context.CheckToolPermissionAsync("http_tool", "https://example.com");
|
||||||
allowed.Should().BeTrue();
|
allowed.Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AgentContext_GetEffectiveToolPermission_PrefersPatternRule()
|
||||||
|
{
|
||||||
|
var context = new AgentContext
|
||||||
|
{
|
||||||
|
Permission = "Ask",
|
||||||
|
ToolPermissions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["process"] = "deny",
|
||||||
|
["process@git *"] = "auto",
|
||||||
|
["*@*.md"] = "ask",
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
context.GetEffectiveToolPermission("process", "git status").Should().Be("auto");
|
||||||
|
context.GetEffectiveToolPermission("process", "powershell -NoProfile").Should().Be("deny");
|
||||||
|
context.GetEffectiveToolPermission("file_read", @"E:\work\README.md").Should().Be("ask");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AgentContext_CheckToolPermissionAsync_UsesPatternRuleWithoutPrompt()
|
||||||
|
{
|
||||||
|
var askCalled = false;
|
||||||
|
var context = new AgentContext
|
||||||
|
{
|
||||||
|
OperationMode = OperationModePolicy.ExternalMode,
|
||||||
|
Permission = "Ask",
|
||||||
|
ToolPermissions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["process@git *"] = "auto",
|
||||||
|
},
|
||||||
|
AskPermission = (_, _) =>
|
||||||
|
{
|
||||||
|
askCalled = true;
|
||||||
|
return Task.FromResult(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var allowed = await context.CheckToolPermissionAsync("process", "git status");
|
||||||
|
allowed.Should().BeTrue();
|
||||||
|
askCalled.Should().BeFalse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
using AxCopilot.Handlers;
|
using AxCopilot.Handlers;
|
||||||
using AxCopilot.SDK;
|
using AxCopilot.SDK;
|
||||||
using AxCopilot.Services;
|
using AxCopilot.Services;
|
||||||
@@ -15,6 +16,9 @@ public class PluginHost
|
|||||||
private readonly SettingsService _settings;
|
private readonly SettingsService _settings;
|
||||||
private readonly CommandResolver _resolver;
|
private readonly CommandResolver _resolver;
|
||||||
private readonly List<IActionHandler> _loadedPlugins = new();
|
private readonly List<IActionHandler> _loadedPlugins = new();
|
||||||
|
private readonly string _pluginsRoot = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"AxCopilot", "plugins");
|
||||||
|
|
||||||
public IReadOnlyList<IActionHandler> LoadedPlugins => _loadedPlugins;
|
public IReadOnlyList<IActionHandler> LoadedPlugins => _loadedPlugins;
|
||||||
|
|
||||||
@@ -58,6 +62,12 @@ public class PluginHost
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!IsPluginPathAllowed(dllPath))
|
||||||
|
{
|
||||||
|
LogService.Warn($"플러그인 경로 정책 차단: {dllPath}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var assembly = Assembly.LoadFrom(dllPath);
|
var assembly = Assembly.LoadFrom(dllPath);
|
||||||
@@ -152,6 +162,14 @@ public class PluginHost
|
|||||||
entry.ExtractToFile(destPath, overwrite: true);
|
entry.ExtractToFile(destPath, overwrite: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (OperationModePolicy.IsInternal(_settings.Settings) &&
|
||||||
|
!TryValidatePluginManifest(targetDir, out var validationError))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(targetDir, recursive: true); } catch { }
|
||||||
|
LogService.Warn($"내부 모드 플러그인 정책 차단: {validationError}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// .dll 파일 찾아서 플러그인으로 등록
|
// .dll 파일 찾아서 플러그인으로 등록
|
||||||
foreach (var dllFile in Directory.EnumerateFiles(targetDir, "*.dll"))
|
foreach (var dllFile in Directory.EnumerateFiles(targetDir, "*.dll"))
|
||||||
{
|
{
|
||||||
@@ -235,4 +253,84 @@ public class PluginHost
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsPluginPathAllowed(string dllPath)
|
||||||
|
{
|
||||||
|
if (!OperationModePolicy.IsInternal(_settings.Settings))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// internal 모드에서는 로컬 plugin root 하위 DLL만 허용
|
||||||
|
return IsPathUnderRoot(dllPath, _pluginsRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPathUnderRoot(string path, string root)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fullPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
return fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(fullPath, fullRoot, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryValidatePluginManifest(string pluginDir, out string error)
|
||||||
|
{
|
||||||
|
error = "";
|
||||||
|
var manifestPath = Path.Combine(pluginDir, ".claude-plugin", "plugin.json");
|
||||||
|
if (!File.Exists(manifestPath))
|
||||||
|
{
|
||||||
|
error = "plugin.json(.claude-plugin) 파일이 없습니다.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(File.ReadAllText(manifestPath));
|
||||||
|
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
error = "plugin.json 형식이 올바르지 않습니다.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var name = root.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||||
|
var version = root.TryGetProperty("version", out var v) ? v.GetString() : null;
|
||||||
|
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(version))
|
||||||
|
{
|
||||||
|
error = "plugin.json의 name/version이 필요합니다.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.TryGetProperty("hooks", out var hooksProp) && hooksProp.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var hookRel = hooksProp.GetString() ?? "";
|
||||||
|
if (!string.IsNullOrWhiteSpace(hookRel))
|
||||||
|
{
|
||||||
|
var fullHookPath = Path.GetFullPath(Path.Combine(pluginDir, hookRel));
|
||||||
|
if (!IsPathUnderRoot(fullHookPath, pluginDir))
|
||||||
|
{
|
||||||
|
error = "hooks 경로가 plugin 디렉터리 밖을 가리킵니다.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!File.Exists(fullHookPath))
|
||||||
|
{
|
||||||
|
error = $"hooks 파일이 없습니다: {hookRel}";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
error = $"plugin.json 파싱 실패: {ex.Message}";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1023,6 +1023,22 @@ public class CodeSettings
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonPropertyName("enableCodeVerification")]
|
[JsonPropertyName("enableCodeVerification")]
|
||||||
public bool EnableCodeVerification { get; set; } = false;
|
public bool EnableCodeVerification { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>Code 탭에서 Plan Mode 도구(enter/exit plan mode) 사용 여부. 기본 true.</summary>
|
||||||
|
[JsonPropertyName("enablePlanModeTools")]
|
||||||
|
public bool EnablePlanModeTools { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Code 탭에서 Worktree 도구(enter/exit worktree) 사용 여부. 기본 true.</summary>
|
||||||
|
[JsonPropertyName("enableWorktreeTools")]
|
||||||
|
public bool EnableWorktreeTools { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Code 탭에서 Team 도구(team create/delete) 사용 여부. 기본 true.</summary>
|
||||||
|
[JsonPropertyName("enableTeamTools")]
|
||||||
|
public bool EnableTeamTools { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Code 탭에서 Cron 도구(cron create/list/delete) 사용 여부. 기본 true.</summary>
|
||||||
|
[JsonPropertyName("enableCronTools")]
|
||||||
|
public bool EnableCronTools { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>사용자 정의 커스텀 프리셋 (settings.json에 저장).</summary>
|
/// <summary>사용자 정의 커스텀 프리셋 (settings.json에 저장).</summary>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ public partial class AgentLoopService
|
|||||||
{
|
{
|
||||||
EmitEvent(AgentEventType.Thinking, "", $"읽기 전용 도구 {calls.Count}개를 병렬 실행 중...");
|
EmitEvent(AgentEventType.Thinking, "", $"읽기 전용 도구 {calls.Count}개를 병렬 실행 중...");
|
||||||
|
|
||||||
var activeToolNames = _tools.GetActiveTools(llm.DisabledTools)
|
var activeToolNames = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides: null)
|
||||||
.Select(t => t.Name)
|
.Select(t => t.Name)
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||||
|
|||||||
@@ -1536,7 +1536,8 @@ public partial class AgentLoopService
|
|||||||
IEnumerable<string>? disabledToolNames,
|
IEnumerable<string>? disabledToolNames,
|
||||||
SkillRuntimeOverrides? runtimeOverrides)
|
SkillRuntimeOverrides? runtimeOverrides)
|
||||||
{
|
{
|
||||||
var active = _tools.GetActiveTools(disabledToolNames);
|
var mergedDisabled = MergeDisabledTools(disabledToolNames);
|
||||||
|
var active = _tools.GetActiveTools(mergedDisabled);
|
||||||
if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0)
|
if (runtimeOverrides == null || runtimeOverrides.AllowedToolNames.Count == 0)
|
||||||
return active;
|
return active;
|
||||||
|
|
||||||
@@ -1546,6 +1547,50 @@ public partial class AgentLoopService
|
|||||||
.AsReadOnly();
|
.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)
|
private static HashSet<string> ParseAllowedToolNames(string? raw)
|
||||||
{
|
{
|
||||||
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -3510,6 +3555,25 @@ public partial class AgentLoopService
|
|||||||
["task"] = "spawn_agent",
|
["task"] = "spawn_agent",
|
||||||
["sendmessage"] = "notify_tool",
|
["sendmessage"] = "notify_tool",
|
||||||
["powershell"] = "process",
|
["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)
|
private static string ResolveRequestedToolName(string requestedToolName, IReadOnlyCollection<string> activeToolNames)
|
||||||
@@ -3858,9 +3922,11 @@ public partial class AgentLoopService
|
|||||||
private AgentContext BuildContext()
|
private AgentContext BuildContext()
|
||||||
{
|
{
|
||||||
var llm = _settings.Settings.Llm;
|
var llm = _settings.Settings.Llm;
|
||||||
|
var baseWorkFolder = llm.WorkFolder;
|
||||||
|
var runtimeWorkFolder = ResolveRuntimeWorkFolder(baseWorkFolder);
|
||||||
return new AgentContext
|
return new AgentContext
|
||||||
{
|
{
|
||||||
WorkFolder = llm.WorkFolder,
|
WorkFolder = runtimeWorkFolder,
|
||||||
Permission = llm.FilePermission,
|
Permission = llm.FilePermission,
|
||||||
BlockedPaths = llm.BlockedPaths,
|
BlockedPaths = llm.BlockedPaths,
|
||||||
BlockedExtensions = llm.BlockedExtensions,
|
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)
|
private static string DescribeToolTarget(string toolName, JsonElement input, AgentContext context)
|
||||||
{
|
{
|
||||||
static string? TryReadString(JsonElement inputElement, params string[] names)
|
static string? TryReadString(JsonElement inputElement, params string[] names)
|
||||||
@@ -4049,7 +4143,7 @@ public partial class AgentLoopService
|
|||||||
runId = _currentRunId,
|
runId = _currentRunId,
|
||||||
tool = toolName,
|
tool = toolName,
|
||||||
target,
|
target,
|
||||||
permission = context.GetEffectiveToolPermission(toolName)
|
permission = context.GetEffectiveToolPermission(toolName, target)
|
||||||
});
|
});
|
||||||
await RunPermissionLifecycleHooksAsync(
|
await RunPermissionLifecycleHooksAsync(
|
||||||
"__permission_request__",
|
"__permission_request__",
|
||||||
@@ -4059,7 +4153,7 @@ public partial class AgentLoopService
|
|||||||
messages,
|
messages,
|
||||||
success: true);
|
success: true);
|
||||||
|
|
||||||
var effectivePerm = context.GetEffectiveToolPermission(toolName);
|
var effectivePerm = context.GetEffectiveToolPermission(toolName, target);
|
||||||
|
|
||||||
if (string.Equals(effectivePerm, "Ask", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(effectivePerm, "Ask", StringComparison.OrdinalIgnoreCase))
|
||||||
EmitEvent(AgentEventType.PermissionRequest, toolName, $"권한 확인 필요 · 대상: {target}");
|
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.IO;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace AxCopilot.Services.Agent;
|
namespace AxCopilot.Services.Agent;
|
||||||
@@ -95,7 +96,7 @@ public class AgentContext
|
|||||||
private readonly object _permissionLock = new();
|
private readonly object _permissionLock = new();
|
||||||
private readonly HashSet<string> _approvedPermissionCache = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _approvedPermissionCache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
/// <summary>작업 폴더 경로.</summary>
|
/// <summary>작업 폴더 경로.</summary>
|
||||||
public string WorkFolder { get; init; } = "";
|
public string WorkFolder { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>파일 접근 권한. Ask | Auto | Deny</summary>
|
/// <summary>파일 접근 권한. Ask | Auto | Deny</summary>
|
||||||
public string Permission { get; init; } = "Ask";
|
public string Permission { get; init; } = "Ask";
|
||||||
@@ -186,8 +187,13 @@ public class AgentContext
|
|||||||
return await CheckToolPermissionAsync(toolName, filePath);
|
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) &&
|
if (ToolPermissions.TryGetValue(toolName, out var toolPerm) &&
|
||||||
!string.IsNullOrWhiteSpace(toolPerm))
|
!string.IsNullOrWhiteSpace(toolPerm))
|
||||||
return toolPerm;
|
return toolPerm;
|
||||||
@@ -207,7 +213,7 @@ public class AgentContext
|
|||||||
&& AxCopilot.Services.OperationModePolicy.IsBlockedAgentToolInInternalMode(toolName, target))
|
&& AxCopilot.Services.OperationModePolicy.IsBlockedAgentToolInInternalMode(toolName, target))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var effectivePerm = GetEffectiveToolPermission(toolName);
|
var effectivePerm = GetEffectiveToolPermission(toolName, target);
|
||||||
if (string.Equals(effectivePerm, "Deny", StringComparison.OrdinalIgnoreCase)) return false;
|
if (string.Equals(effectivePerm, "Deny", StringComparison.OrdinalIgnoreCase)) return false;
|
||||||
if (string.Equals(effectivePerm, "Auto", StringComparison.OrdinalIgnoreCase)) return true;
|
if (string.Equals(effectivePerm, "Auto", StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
if (AskPermission == null) return false;
|
if (AskPermission == null) return false;
|
||||||
@@ -228,6 +234,67 @@ public class AgentContext
|
|||||||
}
|
}
|
||||||
return allowed;
|
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>
|
/// <summary>에이전트 이벤트 (UI 표시용).</summary>
|
||||||
|
|||||||
@@ -462,6 +462,23 @@ public static class SkillService
|
|||||||
["LSP"] = "lsp_code_intel",
|
["LSP"] = "lsp_code_intel",
|
||||||
["ListMcpResourcesTool"] = "mcp_list_resources",
|
["ListMcpResourcesTool"] = "mcp_list_resources",
|
||||||
["ReadMcpResourceTool"] = "mcp_read_resource",
|
["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",
|
["Agent"] = "spawn_agent",
|
||||||
["Task"] = "spawn_agent",
|
["Task"] = "spawn_agent",
|
||||||
["SendMessage"] = "notify_tool",
|
["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 WaitAgentsTool());
|
||||||
registry.Register(new CodeSearchTool());
|
registry.Register(new CodeSearchTool());
|
||||||
registry.Register(new TestLoopTool());
|
registry.Register(new TestLoopTool());
|
||||||
|
registry.Register(new ToolSearchTool(() => registry.All));
|
||||||
|
|
||||||
// 코드 리뷰 + 프로젝트 규칙
|
// 코드 리뷰 + 프로젝트 규칙
|
||||||
registry.Register(new CodeReviewTool());
|
registry.Register(new CodeReviewTool());
|
||||||
@@ -179,6 +180,22 @@ public class ToolRegistry : IDisposable
|
|||||||
|
|
||||||
// 태스크 추적
|
// 태스크 추적
|
||||||
registry.Register(new TaskTrackerTool());
|
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());
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4839,6 +4839,49 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- ── Agentic 도구 설정 ── -->
|
||||||
|
<TextBlock Style="{StaticResource SectionHeader}" Text="Agentic 도구"/>
|
||||||
|
<Border Style="{StaticResource SettingsRow}">
|
||||||
|
<Grid>
|
||||||
|
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
|
||||||
|
<TextBlock Style="{StaticResource RowLabel}" Text="Plan Mode 도구"/>
|
||||||
|
<TextBlock Style="{StaticResource RowHint}" Text="EnterPlanMode/ExitPlanMode 도구를 사용할지 설정합니다."/>
|
||||||
|
</StackPanel>
|
||||||
|
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
|
IsChecked="{Binding Code.EnablePlanModeTools, Mode=TwoWay}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<Border Style="{StaticResource SettingsRow}">
|
||||||
|
<Grid>
|
||||||
|
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
|
||||||
|
<TextBlock Style="{StaticResource RowLabel}" Text="Worktree 도구"/>
|
||||||
|
<TextBlock Style="{StaticResource RowHint}" Text="EnterWorktree/ExitWorktree 도구를 사용할지 설정합니다."/>
|
||||||
|
</StackPanel>
|
||||||
|
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
|
IsChecked="{Binding Code.EnableWorktreeTools, Mode=TwoWay}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<Border Style="{StaticResource SettingsRow}">
|
||||||
|
<Grid>
|
||||||
|
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
|
||||||
|
<TextBlock Style="{StaticResource RowLabel}" Text="Team 도구"/>
|
||||||
|
<TextBlock Style="{StaticResource RowHint}" Text="TeamCreate/TeamDelete 도구를 사용할지 설정합니다."/>
|
||||||
|
</StackPanel>
|
||||||
|
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
|
IsChecked="{Binding Code.EnableTeamTools, Mode=TwoWay}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<Border Style="{StaticResource SettingsRow}">
|
||||||
|
<Grid>
|
||||||
|
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
|
||||||
|
<TextBlock Style="{StaticResource RowLabel}" Text="Cron 도구"/>
|
||||||
|
<TextBlock Style="{StaticResource RowHint}" Text="CronCreate/CronList/CronDelete 도구를 사용할지 설정합니다."/>
|
||||||
|
</StackPanel>
|
||||||
|
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
|
IsChecked="{Binding Code.EnableCronTools, Mode=TwoWay}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- ── 코드 인텔리전스 (v1.4.0) ── -->
|
<!-- ── 코드 인텔리전스 (v1.4.0) ── -->
|
||||||
<TextBlock Style="{StaticResource SectionHeader}" Text="코드 인텔리전스"/>
|
<TextBlock Style="{StaticResource SectionHeader}" Text="코드 인텔리전스"/>
|
||||||
<Border Style="{StaticResource SettingsRow}">
|
<Border Style="{StaticResource SettingsRow}">
|
||||||
|
|||||||
Reference in New Issue
Block a user