스킬 런타임 2차 고도화와 도구 노출 필터 정비
프로젝트 .claude/skills 재귀 로드와 namespaced SKILL.md 파싱을 추가하고 번들/사용자/프로젝트 스킬을 함께 노출하도록 SkillService와 설정 UI를 확장했다. 슬래시 스킬 호출 시 인자 치환, 스킬 폴더 변수 치환, inline shell 실행, when_to_use 기반 자동 스킬 가이드를 실제 ChatWindow 런타임 경로에 연결했다. blanket deny 권한은 모델 노출 전 활성 도구 목록에서 먼저 제외하도록 AgentLoopService를 보강했고 관련 테스트와 README/DEVELOPMENT 문서를 업데이트했다. 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase2\\ -p:IntermediateOutputPath=obj\\verify_phase2\\ (경고 0 / 오류 0) 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase2_tests\\ -p:IntermediateOutputPath=obj\\verify_phase2_tests\\ (통과 16, 기존 WorkspaceContextGeneratorTests nullable 경고 1건 유지)
This commit is contained in:
@@ -40,7 +40,7 @@ public partial class AgentSettingsWindow : Window
|
||||
private void LoadFromSettings()
|
||||
{
|
||||
SkillService.EnsureSkillFolder();
|
||||
SkillService.LoadSkills(_llm.SkillsFolderPath);
|
||||
SkillService.LoadSkills(_llm.SkillsFolderPath, ResolveSkillProjectRoot());
|
||||
|
||||
_selectedService = (_llm.Service ?? "ollama").Trim().ToLowerInvariant();
|
||||
_selectedTheme = (_llm.AgentTheme ?? "system").Trim().ToLowerInvariant();
|
||||
@@ -564,12 +564,30 @@ public partial class AgentSettingsWindow : Window
|
||||
_settings.Settings.AiEnabled = true;
|
||||
_settings.Settings.OperationMode = OperationModePolicy.Normalize(_operationMode);
|
||||
_settings.Save();
|
||||
SkillService.LoadSkills(_llm.SkillsFolderPath);
|
||||
SkillService.LoadSkills(_llm.SkillsFolderPath, ResolveSkillProjectRoot());
|
||||
BuildSkillListPanel();
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private string? ResolveSkillProjectRoot()
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
_llm.CodeWorkFolder,
|
||||
_llm.CoworkWorkFolder,
|
||||
_llm.WorkFolder
|
||||
};
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(candidate) && System.IO.Directory.Exists(candidate))
|
||||
return candidate.Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void BtnBrowseSkillFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new System.Windows.Forms.FolderBrowserDialog
|
||||
@@ -611,7 +629,7 @@ public partial class AgentSettingsWindow : Window
|
||||
Padding = new Thickness(12, 10, 12, 10),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = "로드된 스킬이 없습니다. 스킬 폴더를 열어 `.skill.md` 또는 `SKILL.md` 파일을 추가한 뒤 저장하면 다시 불러옵니다.",
|
||||
Text = "로드된 스킬이 없습니다. 기본/추가 스킬 폴더 또는 프로젝트 `.claude/skills` 아래에 `.skill.md`나 `SKILL.md`를 추가한 뒤 저장하면 다시 불러옵니다.",
|
||||
FontSize = 11,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
||||
@@ -622,8 +640,10 @@ public partial class AgentSettingsWindow : Window
|
||||
|
||||
var groups = new[]
|
||||
{
|
||||
new { Title = "내장 스킬", Items = skills.Where(s => string.IsNullOrWhiteSpace(s.Requires)).ToList() },
|
||||
new { Title = "고급 스킬", Items = skills.Where(s => !string.IsNullOrWhiteSpace(s.Requires)).ToList() },
|
||||
new { Title = "번들 스킬", Items = skills.Where(s => string.Equals(s.SourceScope, "bundled", StringComparison.OrdinalIgnoreCase)).ToList() },
|
||||
new { Title = "프로젝트 스킬", Items = skills.Where(s => string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)).ToList() },
|
||||
new { Title = "사용자/추가 스킬", Items = skills.Where(s => !string.Equals(s.SourceScope, "bundled", StringComparison.OrdinalIgnoreCase) && !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(s.Requires)).ToList() },
|
||||
new { Title = "고급 스킬", Items = skills.Where(s => !string.IsNullOrWhiteSpace(s.Requires) && !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)).ToList() },
|
||||
};
|
||||
|
||||
foreach (var group in groups)
|
||||
|
||||
@@ -1284,7 +1284,7 @@ public partial class ChatWindow
|
||||
return;
|
||||
|
||||
_settings.Settings.Llm.SkillsFolderPath = dlg.SelectedPath;
|
||||
SkillService.LoadSkills(dlg.SelectedPath);
|
||||
SkillService.LoadSkills(dlg.SelectedPath, GetCurrentWorkFolder());
|
||||
RefreshOverlayEtcPanels();
|
||||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||||
}
|
||||
@@ -1887,12 +1887,11 @@ public partial class ChatWindow
|
||||
.Where(skill => !skill.IsAvailable)
|
||||
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var autoSkills = skills
|
||||
.Where(skill => skill.IsAvailable && (!skill.UserInvocable || !string.IsNullOrWhiteSpace(skill.Paths)))
|
||||
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var autoSkills = SkillService.GetAutoSkills(_activeTab).ToList();
|
||||
var directSkills = skills
|
||||
.Where(skill => skill.IsAvailable && skill.UserInvocable && string.IsNullOrWhiteSpace(skill.Paths))
|
||||
.Where(skill => skill.IsAvailable
|
||||
&& skill.UserInvocable
|
||||
&& !autoSkills.Any(autoSkill => string.Equals(autoSkill.Name, skill.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
@@ -3652,7 +3651,7 @@ public partial class ChatWindow
|
||||
if (llm.EnableSkillSystem)
|
||||
{
|
||||
SkillService.EnsureSkillFolder();
|
||||
SkillService.LoadSkills(llm.SkillsFolderPath);
|
||||
SkillService.LoadSkills(llm.SkillsFolderPath, GetCurrentWorkFolder());
|
||||
UpdateConditionalSkillActivation(reset: true);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ public partial class ChatWindow
|
||||
? "[FORK]"
|
||||
: "[DIRECT]";
|
||||
var baseLabel = $"{badge} {skill.Label}";
|
||||
if (!string.IsNullOrWhiteSpace(skill.ArgumentHint))
|
||||
baseLabel = $"{baseLabel} {skill.ArgumentHint.Trim()}";
|
||||
return skill.IsAvailable ? baseLabel : $"{baseLabel} {skill.UnavailableHint}";
|
||||
}
|
||||
|
||||
|
||||
@@ -524,7 +524,7 @@ public partial class ChatWindow : Window
|
||||
if (_settings.Settings.Llm.EnableSkillSystem)
|
||||
{
|
||||
SkillService.EnsureSkillFolder();
|
||||
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath);
|
||||
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath, GetCurrentWorkFolder());
|
||||
UpdateConditionalSkillActivation(reset: true);
|
||||
}
|
||||
|
||||
@@ -2241,6 +2241,15 @@ public partial class ChatWindow : Window
|
||||
return _settings.Settings.Llm.WorkFolder;
|
||||
}
|
||||
|
||||
private void EnsureSkillSystemLoadedForCurrentWorkspace()
|
||||
{
|
||||
if (!_settings.Settings.Llm.EnableSkillSystem)
|
||||
return;
|
||||
|
||||
SkillService.EnsureSkillFolder();
|
||||
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath, GetCurrentWorkFolder());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 작업 컨텍스트(첨부 파일 + 작업 폴더) 기준으로
|
||||
/// 조건부 paths 스킬 활성화를 갱신합니다.
|
||||
@@ -2248,6 +2257,7 @@ public partial class ChatWindow : Window
|
||||
private void UpdateConditionalSkillActivation(bool reset = false)
|
||||
{
|
||||
if (!_settings.Settings.Llm.EnableSkillSystem) return;
|
||||
EnsureSkillSystemLoadedForCurrentWorkspace();
|
||||
var cwd = GetCurrentWorkFolder();
|
||||
if (string.IsNullOrWhiteSpace(cwd) || !System.IO.Directory.Exists(cwd)) return;
|
||||
if (reset) SkillService.ResetConditionalSkillActivation();
|
||||
@@ -3460,6 +3470,7 @@ public partial class ChatWindow : Window
|
||||
// 스킬 슬래시 명령어 매칭 (탭별 필터)
|
||||
if (_settings.Settings.Llm.EnableSkillSystem)
|
||||
{
|
||||
EnsureSkillSystemLoadedForCurrentWorkspace();
|
||||
var skillMatches = SkillService.MatchSlashCommand(text)
|
||||
.Where(s => s.IsVisibleInTab(_activeTab))
|
||||
.Select(s => (Cmd: "/" + s.Name,
|
||||
@@ -3518,7 +3529,7 @@ public partial class ChatWindow : Window
|
||||
}
|
||||
|
||||
/// <summary>슬래시 명령어를 감지하여 시스템 프롬프트와 사용자 텍스트를 분리합니다.</summary>
|
||||
private (string? slashSystem, string userText) ParseSlashCommand(string input)
|
||||
private async Task<(string? slashSystem, string userText)> ParseSlashCommandAsync(string input, CancellationToken ct = default)
|
||||
{
|
||||
var trimmed = input.TrimStart();
|
||||
if (trimmed.StartsWith("/"))
|
||||
@@ -3535,17 +3546,10 @@ public partial class ChatWindow : Window
|
||||
}
|
||||
|
||||
// 스킬 명령어 매칭
|
||||
var matchedSkill = SkillService.MatchSlashInvocation(input);
|
||||
if (matchedSkill != null)
|
||||
{
|
||||
var slashCmd = "/" + matchedSkill.Name;
|
||||
var rest = input[slashCmd.Length..].Trim();
|
||||
var runtimePolicy = SkillService.BuildRuntimeDirective(matchedSkill);
|
||||
var mergedPrompt = string.IsNullOrWhiteSpace(runtimePolicy)
|
||||
? matchedSkill.SystemPrompt
|
||||
: $"{matchedSkill.SystemPrompt}\n\n{runtimePolicy}";
|
||||
return (mergedPrompt, string.IsNullOrEmpty(rest) ? matchedSkill.Label : rest);
|
||||
}
|
||||
EnsureSkillSystemLoadedForCurrentWorkspace();
|
||||
var compiledInvocation = await SkillService.BuildSlashInvocationAsync(input, GetCurrentWorkFolder(), ct);
|
||||
if (compiledInvocation != null)
|
||||
return (compiledInvocation.SystemPrompt, compiledInvocation.DisplayText);
|
||||
|
||||
return (null, input);
|
||||
}
|
||||
@@ -5004,7 +5008,7 @@ public partial class ChatWindow : Window
|
||||
{
|
||||
llm.EnableSkillSystem = true;
|
||||
SkillService.EnsureSkillFolder();
|
||||
SkillService.LoadSkills(llm.SkillsFolderPath);
|
||||
SkillService.LoadSkills(llm.SkillsFolderPath, GetCurrentWorkFolder());
|
||||
UpdateConditionalSkillActivation(reset: true);
|
||||
ScheduleSettingsSave();
|
||||
_appState.LoadFromSettings(_settings);
|
||||
@@ -5102,7 +5106,17 @@ public partial class ChatWindow : Window
|
||||
ClearPromptCardPlaceholder();
|
||||
|
||||
// 슬래시 명령어 처리
|
||||
var (slashSystem, displayText) = ParseSlashCommand(text);
|
||||
var (slashSystem, displayText) = await ParseSlashCommandAsync(text, CancellationToken.None);
|
||||
|
||||
if (slashSystem == null
|
||||
&& _settings.Settings.Llm.EnableSkillSystem
|
||||
&& !text.TrimStart().StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
EnsureSkillSystemLoadedForCurrentWorkspace();
|
||||
var autoSkillPrompt = await SkillService.BuildProactiveSkillSystemPromptAsync(displayText, _activeTab, GetCurrentWorkFolder(), CancellationToken.None);
|
||||
if (!string.IsNullOrWhiteSpace(autoSkillPrompt))
|
||||
slashSystem = autoSkillPrompt;
|
||||
}
|
||||
|
||||
if (string.Equals(slashSystem, "__CLEAR__", StringComparison.Ordinal))
|
||||
{
|
||||
|
||||
@@ -4309,16 +4309,17 @@
|
||||
<ToolTip Style="{StaticResource HelpTooltipStyle}">
|
||||
<TextBlock TextWrapping="Wrap" Foreground="White" FontSize="12" LineHeight="18" MaxWidth="320">
|
||||
마크다운 기반 재사용 워크플로우 시스템입니다.
|
||||
<LineBreak/>사용자 스킬 폴더와 추가 폴더의 스킬을 로드해 슬래시 명령어(/)와 런타임 정책에 연결합니다.
|
||||
<LineBreak/>번들 스킬, 사용자 스킬 폴더, 프로젝트 `.claude/skills`의 스킬을 함께 로드해 슬래시 명령어(/)와 보조 런타임 지침에 연결합니다.
|
||||
<LineBreak/>
|
||||
<LineBreak/>기본 스킬 폴더: %APPDATA%\AxCopilot\skills\
|
||||
<LineBreak/>예: /daily-standup, /bug-hunt, /code-explain
|
||||
<LineBreak/>프로젝트 폴더: [작업폴더]\.claude\skills\
|
||||
<LineBreak/>예: /daily-standup, /verify-change, /handoff-note
|
||||
</TextBlock>
|
||||
</ToolTip>
|
||||
</Border.ToolTip>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock Style="{StaticResource RowHint}" Text="마크다운 기반 재사용 워크플로우. 슬래시 명령과 런타임 정책에서 함께 활용됩니다."/>
|
||||
<TextBlock Style="{StaticResource RowHint}" Text="마크다운 기반 재사용 워크플로우. 슬래시 명령, 프로젝트 스킬, 자동/조건부 보조 지침에서 함께 활용됩니다."/>
|
||||
</StackPanel>
|
||||
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
IsChecked="{Binding EnableSkillSystem, Mode=TwoWay}"/>
|
||||
|
||||
@@ -94,7 +94,9 @@ public partial class SettingsWindow : Window
|
||||
await Task.Run(() =>
|
||||
{
|
||||
Services.Agent.SkillService.EnsureSkillFolder();
|
||||
Services.Agent.SkillService.LoadSkills(app?.SettingsService?.Settings.Llm.SkillsFolderPath);
|
||||
Services.Agent.SkillService.LoadSkills(
|
||||
app?.SettingsService?.Settings.Llm.SkillsFolderPath,
|
||||
ResolveSkillProjectRoot(app?.SettingsService?.Settings.Llm));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,6 +117,27 @@ public partial class SettingsWindow : Window
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveSkillProjectRoot(Models.LlmSettings? llm)
|
||||
{
|
||||
if (llm == null)
|
||||
return null;
|
||||
|
||||
var candidates = new[]
|
||||
{
|
||||
llm.CodeWorkFolder,
|
||||
llm.CoworkWorkFolder,
|
||||
llm.WorkFolder
|
||||
};
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(candidate) && Directory.Exists(candidate))
|
||||
return candidate.Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void BindIndexProgress()
|
||||
{
|
||||
_indexService = (System.Windows.Application.Current as App)?.IndexService;
|
||||
@@ -616,26 +639,40 @@ public partial class SettingsWindow : Window
|
||||
// 설명
|
||||
skillItems.Add(new TextBlock
|
||||
{
|
||||
Text = "/ 명령으로 호출할 수 있는 스킬 목록입니다. 앱 내장 + 사용자 추가 스킬이 포함됩니다.\n" +
|
||||
"(직접 호출 가능한 스킬과 런타임 정책에 연결되는 스킬을 함께 표시합니다.)",
|
||||
Text = "/ 명령으로 호출할 수 있는 스킬 목록입니다. 번들 스킬, 사용자/추가 스킬, 프로젝트 `.claude/skills`가 함께 포함됩니다.\n" +
|
||||
"(직접 호출 스킬과 자동/조건부 보조 스킬을 함께 표시합니다.)",
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(subtleText),
|
||||
Margin = new Thickness(2, 0, 0, 10),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
});
|
||||
|
||||
// 내장 스킬 / 고급 스킬 분류
|
||||
var builtIn = skills.Where(s => string.IsNullOrEmpty(s.Requires)).ToList();
|
||||
var advanced = skills.Where(s => !string.IsNullOrEmpty(s.Requires)).ToList();
|
||||
var bundled = skills.Where(s => string.Equals(s.SourceScope, "bundled", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
var project = skills.Where(s => string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
var custom = skills.Where(s => !string.Equals(s.SourceScope, "bundled", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.IsNullOrEmpty(s.Requires)).ToList();
|
||||
var advanced = skills.Where(s => !string.IsNullOrEmpty(s.Requires)
|
||||
&& !string.Equals(s.SourceScope, "project", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
// 내장 스킬 카드
|
||||
if (builtIn.Count > 0)
|
||||
if (bundled.Count > 0)
|
||||
{
|
||||
var card = CreateSkillGroupCard("내장 스킬", "\uE768", "#34D399", builtIn);
|
||||
var card = CreateSkillGroupCard("번들 스킬", "\uE768", "#34D399", bundled);
|
||||
skillItems.Add(card);
|
||||
}
|
||||
|
||||
if (project.Count > 0)
|
||||
{
|
||||
var card = CreateSkillGroupCard("프로젝트 스킬", "\uE8F1", "#2563EB", project);
|
||||
skillItems.Add(card);
|
||||
}
|
||||
|
||||
if (custom.Count > 0)
|
||||
{
|
||||
var card = CreateSkillGroupCard("사용자/추가 스킬", "\uE70F", "#F59E0B", custom);
|
||||
skillItems.Add(card);
|
||||
}
|
||||
|
||||
// 고급 스킬 (런타임 의존) 카드
|
||||
if (advanced.Count > 0)
|
||||
{
|
||||
var card = CreateSkillGroupCard("고급 스킬 (런타임 필요)", "\uE9D9", "#A78BFA", advanced);
|
||||
|
||||
Reference in New Issue
Block a user