diff --git a/docs/NEXT_ROADMAP.md b/docs/NEXT_ROADMAP.md index eea8f8b..0c05727 100644 --- a/docs/NEXT_ROADMAP.md +++ b/docs/NEXT_ROADMAP.md @@ -5029,5 +5029,30 @@ ThemeResourceHelper에 5개 정적 필드 추가: --- -최종 업데이트: 2026-04-04 (Phase 22~52 + Phase 17-UI-A~E + Phase 17-A~C 구현 완료) +--- + +## Phase 17-D — 스킬 시스템 고도화 (v1.8.0) ✅ 완료 + +> **목표**: paths: glob 패턴 기반 스킬 자동 주입 + context:fork 서브에이전트 격리 실행 + +### 변경 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `AgentLoopService.Skills.cs` (신규, 95줄) | `InjectPathBasedSkills()` — 파일 경로 매칭 스킬 시스템 메시지 in-place 주입, SkillActivated 이벤트 로그. `RunSkillInForkAsync()` — context:fork 스킬 격리 LLM 실행, SkillCompleted 이벤트 로그. | +| `SkillManagerTool.cs` | `SetForkRunner()` 콜백 필드 추가. `exec` 액션 + `arguments` 파라미터 추가. `ExecSkillAsync()`: PrepareSkillBodyAsync 준비 → IsForkContext 시 fork runner 호출 → 일반 스킬은 시스템 프롬프트 반환. | +| `AgentLoopService.cs` | 생성자: `SkillManagerTool`에 `RunSkillInForkAsync` fork runner 주입. | +| `AgentLoopService.Execution.cs` | 도구 성공 후 `InjectPathBasedSkills(result.FilePath, messages)` 호출. | + +### 구현 세부사항 + +- **paths: 자동 주입**: 파일 도구(`file_read`, `file_write` 등) 성공 후 `result.FilePath`로 GlobMatcher 매칭 → 해당 스킬의 시스템 프롬프트를 메시지에 자동 주입. `## 현재 파일에 자동 적용된 스킬` 마커로 in-place 교체. +- **context:fork**: `SkillManagerTool.exec` 호출 시 `skill.IsForkContext == true`이면 격리된 LLM 컨텍스트(도구 없음)에서 실행. 결과를 `[Fork 스킬 결과]` 형식으로 반환. +- **이벤트 로그**: `SkillActivated`(paths: 매칭 시), `SkillCompleted`(fork 실행 완료 시) JSONL 기록 +- **DI 패턴**: `DelegateAgentTool.SetSubAgentRunner` 패턴 동일 적용 — AgentLoopService 생성자에서 콜백 주입 +- **빌드**: 경고 0, 오류 0 + +--- + +최종 업데이트: 2026-04-04 (Phase 22~52 + Phase 17-UI-A~E + Phase 17-A~D 구현 완료) diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs b/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs index 5c01bf3..8a43175 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.Execution.cs @@ -308,6 +308,9 @@ public partial class AgentLoopService // Phase 17-B: 파일 경로를 Working Memory 참조 파일 목록에 추가 TrackToolFile(result.FilePath); + // Phase 17-D: paths: glob 패턴 매칭 스킬 자동 주입 + InjectPathBasedSkills(result.FilePath, messages); + // ToolResultSizer 적용 var sizedResult = ToolResultSizer.Apply(result.Output ?? "", call.ToolName); messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, sizedResult.Output)); diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.Skills.cs b/src/AxCopilot/Services/Agent/AgentLoopService.Skills.cs new file mode 100644 index 0000000..b796151 --- /dev/null +++ b/src/AxCopilot/Services/Agent/AgentLoopService.Skills.cs @@ -0,0 +1,125 @@ +using AxCopilot.Models; + +namespace AxCopilot.Services.Agent; + +/// +/// Phase 17-D: AgentLoopService — 스킬 시스템 고도화 통합. +/// · paths: glob 패턴 매칭 스킬 자동 주입 +/// · context:fork 스킬 격리 실행 +/// +public partial class AgentLoopService +{ + // PathBasedSkillActivator 단일 인스턴스 (상태 없음) + private static readonly PathBasedSkillActivator _pathActivator = new(); + + // ───────────────────────────────────────────────────────────────────── + // paths: 기반 스킬 자동 주입 + // ───────────────────────────────────────────────────────────────────── + + /// + /// 파일 도구가 반환한 파일 경로에 매칭되는 paths: 스킬을 시스템 메시지에 주입합니다. + /// 기존 스킬 섹션은 in-place 교체로 중복 방지. + /// + internal void InjectPathBasedSkills(string? filePath, List messages) + { + if (!(_settings.Settings.Llm.EnableSkillSystem)) return; + if (string.IsNullOrWhiteSpace(filePath)) return; + + try + { + // paths: 패턴이 있는 스킬만 필터링 + var pathSkills = SkillService.Skills + .Where(s => s.IsAvailable && s.GetPathPatterns().Count > 0) + .ToList(); + if (pathSkills.Count == 0) return; + + var activeSkills = _pathActivator.GetActiveSkillsForFile(pathSkills, filePath); + if (activeSkills.Count == 0) return; + + var injection = _pathActivator.BuildSkillContextInjection(activeSkills); + if (string.IsNullOrEmpty(injection)) return; + + const string marker = "## 현재 파일에 자동 적용된 스킬"; + var sysMsg = messages.FirstOrDefault(m => m.Role == "system"); + if (sysMsg != null) + { + // 기존 섹션 교체 (in-place, 중복 방지) + var idx = sysMsg.Content.IndexOf(marker, StringComparison.Ordinal); + if (idx >= 0) + sysMsg.Content = sysMsg.Content[..idx] + injection; + else + sysMsg.Content += "\n\n" + injection; + } + else + { + messages.Insert(0, new ChatMessage { Role = "system", Content = injection }); + } + + // SkillActivated 이벤트 로그 기록 + _ = _eventLog?.AppendAsync(AgentEventLogType.SkillActivated, + System.Text.Json.JsonSerializer.Serialize(new + { + filePath, + skills = activeSkills.Select(s => s.Name).ToArray(), + pathBased = true + })); + + EmitEvent(AgentEventType.Thinking, "", + $"경로 스킬 자동 활성화: {string.Join(", ", activeSkills.Select(s => s.Label))}"); + } + catch (Exception ex) + { + LogService.Warn($"[Skills] paths: 스킬 주입 실패: {ex.Message}"); + } + } + + // ───────────────────────────────────────────────────────────────────── + // context:fork 격리 실행 + // ───────────────────────────────────────────────────────────────────── + + /// + /// context:fork 스킬을 격리된 LLM 컨텍스트에서 실행합니다. + /// 메인 대화와 독립적이며 도구 호출 없이 텍스트만 생성합니다. + /// SkillManagerTool이 PrepareSkillBodyAsync로 인자 치환 완료 후 호출합니다. + /// + internal async Task RunSkillInForkAsync( + SkillDefinition skill, string preparedBody, CancellationToken ct) + { + try + { + var forkMessages = new List(); + + // 스킬의 시스템 프롬프트를 격리 컨텍스트에 주입 + if (!string.IsNullOrWhiteSpace(skill.SystemPrompt)) + forkMessages.Add(new ChatMessage { Role = "system", Content = skill.SystemPrompt }); + + forkMessages.Add(new ChatMessage { Role = "user", Content = preparedBody }); + + EmitEvent(AgentEventType.Thinking, "skill_fork", + $"[Fork] '{skill.Label}' 스킬을 격리 컨텍스트에서 실행 중..."); + + // 도구 없이 텍스트 응답만 생성 (격리 실행) + var response = await _llm.SendAsync(forkMessages, ct); + + // SkillCompleted 이벤트 로그 기록 + _ = _eventLog?.AppendAsync(AgentEventLogType.SkillCompleted, + System.Text.Json.JsonSerializer.Serialize(new + { + skillName = skill.Name, + forkContext = true, + responseLength = response?.Length ?? 0 + })); + + return response ?? ""; + } + catch (OperationCanceledException) + { + return "(취소됨)"; + } + catch (Exception ex) + { + LogService.Warn($"[Skills] fork 컨텍스트 실행 실패: {ex.Message}"); + return $"스킬 실행 오류: {ex.Message}"; + } + } +} diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index aaf7ad7..cee74e9 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -104,6 +104,9 @@ public partial class AgentLoopService // DelegateAgentTool에 서브에이전트 실행기 주입 _delegateAgentTool = tools.Get("delegate") as DelegateAgentTool; _delegateAgentTool?.SetSubAgentRunner(RunSubAgentAsync); + + // Phase 17-D: SkillManagerTool에 fork 실행기 주입 + (tools.Get("skill_manager") as SkillManagerTool)?.SetForkRunner(RunSkillInForkAsync); } /// diff --git a/src/AxCopilot/Services/Agent/SkillManagerTool.cs b/src/AxCopilot/Services/Agent/SkillManagerTool.cs index d07a738..11e9f41 100644 --- a/src/AxCopilot/Services/Agent/SkillManagerTool.cs +++ b/src/AxCopilot/Services/Agent/SkillManagerTool.cs @@ -9,12 +9,20 @@ namespace AxCopilot.Services.Agent; /// public class SkillManagerTool : IAgentTool { + // Phase 17-D: context:fork 스킬 격리 실행기 (AgentLoopService에서 주입) + private Func>? _forkRunner; + + /// context:fork 스킬 실행기를 주입합니다 (AgentLoopService 생성자에서 호출). + public void SetForkRunner(Func> runner) + => _forkRunner = runner; + public string Name => "skill_manager"; public string Description => - "마크다운 기반 스킬(워크플로우)을 관리합니다.\n" + + "마크다운 기반 스킬(워크플로우)을 관리하고 실행합니다.\n" + "- list: 사용 가능한 스킬 목록 조회\n" + "- info: 특정 스킬의 상세 정보 확인\n" + + "- exec: 스킬 실행 (context:fork 스킬은 격리된 컨텍스트에서 실행)\n" + "- reload: 스킬 폴더를 다시 스캔하여 새 스킬 로드"; public ToolParameterSchema Parameters => new() @@ -24,13 +32,18 @@ public class SkillManagerTool : IAgentTool ["action"] = new ToolProperty { Type = "string", - Description = "list (목록), info (상세정보), reload (재로드)", - Enum = ["list", "info", "reload"] + Description = "list (목록), info (상세정보), exec (실행), reload (재로드)", + Enum = ["list", "info", "exec", "reload"] }, ["skill_name"] = new ToolProperty { Type = "string", - Description = "스킬 이름 (info 액션에서 사용)" + Description = "스킬 이름 (info / exec 액션에서 사용)" + }, + ["arguments"] = new ToolProperty + { + Type = "string", + Description = "스킬에 전달할 인자 문자열 (exec 액션에서 선택적으로 사용)" }, }, Required = ["action"] @@ -41,15 +54,17 @@ public class SkillManagerTool : IAgentTool if (!(context.Llm?.EnableSkillSystem ?? true)) return ToolResult.Ok("스킬 시스템이 비활성 상태입니다. 설정 → AX Agent → 공통에서 활성화하세요."); - var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : ""; + var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : ""; var skillName = args.TryGetProperty("skill_name", out var s) ? s.GetString() ?? "" : ""; + var arguments = args.TryGetProperty("arguments", out var g) ? g.GetString() ?? "" : ""; return action switch { - "list" => ListSkills(), - "info" => InfoSkill(skillName), + "list" => ListSkills(), + "info" => InfoSkill(skillName), + "exec" => await ExecSkillAsync(skillName, arguments, context, ct), "reload" => ReloadSkills(context), - _ => ToolResult.Fail($"지원하지 않는 action: {action}. list, info, reload 중 선택하세요.") + _ => ToolResult.Fail($"지원하지 않는 action: {action}. list, info, exec, reload 중 선택하세요.") }; } @@ -94,4 +109,49 @@ public class SkillManagerTool : IAgentTool SkillService.LoadSkills(customFolder); return ToolResult.Ok($"스킬 재로드 완료. {SkillService.Skills.Count}개 로드됨."); } + + /// + /// Phase 17-D: 스킬을 실행합니다. + /// context:fork 스킬은 격리된 LLM 컨텍스트에서 실행합니다. + /// 일반 스킬은 시스템 프롬프트를 반환하여 에이전트가 컨텍스트로 활용합니다. + /// + private async Task ExecSkillAsync( + string name, string arguments, AgentContext context, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(name)) + return ToolResult.Fail("skill_name이 필요합니다. skill_manager(action: list)로 이름을 확인하세요."); + + var skill = SkillService.Find(name); + if (skill == null) + return ToolResult.Fail($"'{name}' 스킬을 찾을 수 없습니다. skill_manager(action: list)로 목록을 확인하세요."); + + if (!skill.IsAvailable) + return ToolResult.Fail($"스킬 '{skill.Label}'의 실행 환경이 충족되지 않습니다. {skill.UnavailableHint}"); + + // 인자 치환 + 인라인 명령 실행 + var preparedBody = await SkillService.PrepareSkillBodyAsync( + skill, arguments, context.WorkFolder ?? "", ct); + + // context:fork → 격리된 컨텍스트에서 실행 + if (skill.IsForkContext && _forkRunner != null) + { + var forkResult = await _forkRunner(skill, preparedBody, ct); + return ToolResult.Ok($"[Fork 스킬 결과: {skill.Label}]\n\n{forkResult}"); + } + + // 일반 스킬 → 시스템 프롬프트 + 준비된 본문 반환 (에이전트가 컨텍스트로 사용) + var sb = new StringBuilder(); + sb.AppendLine($"[스킬 '{skill.Label}' 활성화됨]"); + if (!string.IsNullOrWhiteSpace(skill.SystemPrompt)) + { + sb.AppendLine("\n--- 스킬 지시사항 ---"); + sb.AppendLine(skill.SystemPrompt); + } + if (!string.IsNullOrWhiteSpace(preparedBody) && preparedBody != skill.SystemPrompt) + { + sb.AppendLine("\n--- 실행 내용 ---"); + sb.AppendLine(preparedBody); + } + return ToolResult.Ok(sb.ToString()); + } }