Code 탭 자동 스킬 오탐과 런타임 도구 제한 회귀 수정

원인: proactive auto skill이 실제 매치가 없어도 기본 점수만으로 선택되고, guidance 단계에서 allowed_tools 같은 하드 런타임 정책까지 자동 주입되어 빈 작업 폴더 요청에서 file_write가 빠진 채 종료됐습니다.

수정: SkillService의 proactive skill 점수를 실제 키워드·경로 신호 중심으로 다시 계산하고, auto skill은 guidance만 제공하며 하드 runtime policy는 더 이상 자동 주입하지 않도록 변경했습니다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_auto_skill_runtime_fix\ -p:IntermediateOutputPath=obj\verify_auto_skill_runtime_fix\ (경고 0 / 오류 0), dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter SkillServiceRuntimePolicyTests|FullyQualifiedName~RunAsync_EmptyWorkspace_BlocksExternalFallbackAndRecoversToFileWrite|FullyQualifiedName~RunAsync_EmptyWorkspace_DisallowsSkillManagerAndRecoversToFileWrite -p:OutputPath=bin\verify_auto_skill_runtime_fix_tests\ -p:IntermediateOutputPath=obj\verify_auto_skill_runtime_fix_tests\ (통과 15)
This commit is contained in:
2026-04-15 19:50:35 +09:00
parent 8721a0d8c7
commit f3717cda21
4 changed files with 95 additions and 13 deletions

View File

@@ -263,6 +263,70 @@ public class SkillServiceRuntimePolicyTests
}
}
[Fact]
public async Task BuildProactiveSkillSystemPromptAsync_ReturnsNull_WhenNothingMeaningfullyMatches()
{
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-skill-proactive-none-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
SkillService.LoadSkills(projectRoot: tempDir);
var prompt = await SkillService.BuildProactiveSkillSystemPromptAsync(
"실시간으로 시간을 표시해주는 웹페이지를 만들어",
"Code",
tempDir);
prompt.Should().BeNull();
}
finally
{
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
}
}
[Fact]
public async Task BuildProactiveSkillSystemPromptAsync_DoesNotInjectHardRuntimePolicy()
{
var tempDir = Path.Combine(Path.GetTempPath(), "axcopilot-skill-proactive-runtime-" + Guid.NewGuid().ToString("N"));
var skillDir = Path.Combine(tempDir, ".claude", "skills", "triage");
Directory.CreateDirectory(skillDir);
var skillPath = Path.Combine(skillDir, "SKILL.md");
try
{
var content = """
---
user-invocable: false
tabs: code
when_to_use: build failure test error
allowed-tools:
- file_read
- build_run
---
Investigate failures.
""";
File.WriteAllText(skillPath, content, Encoding.UTF8);
SkillService.LoadSkills(projectRoot: tempDir);
var prompt = await SkillService.BuildProactiveSkillSystemPromptAsync(
"build failure and test error in pipeline",
"Code",
tempDir);
prompt.Should().NotBeNull();
prompt.Should().Contain("[Auto Skill: triage]");
prompt.Should().Contain("Investigate failures.");
prompt.Should().NotContain("[Skill Runtime Policy]");
prompt.Should().NotContain("allowed_tools:");
}
finally
{
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
}
}
[Fact]
public async Task LoadSkills_LoadsAncestorProjectSkills_AndLegacyCommandFiles()
{

View File

@@ -178,11 +178,12 @@ public static class SkillService
foreach (var skill in selected)
{
var compiledPrompt = await CompileSkillPromptAsync(skill, "", workFolder, ct).ConfigureAwait(false);
var runtimePolicy = BuildRuntimeDirective(skill);
var payload = string.IsNullOrWhiteSpace(runtimePolicy)
? compiledPrompt
: $"{compiledPrompt}\n\n{runtimePolicy}";
sections.Add($"[Auto Skill: {skill.Name}]\n{payload}");
if (string.IsNullOrWhiteSpace(compiledPrompt))
continue;
// Auto skills are lightweight guidance only. Hard runtime overrides such as
// allowed_tools or model/context switches must stay opt-in via explicit skill use.
sections.Add($"[Auto Skill: {skill.Name}]\n{compiledPrompt}");
}
return sections.Count == 0
@@ -1292,10 +1293,12 @@ public static class SkillService
.Select(skill => new
{
Skill = skill,
Score = ScoreSkill(skill, userText, queryTokens)
Score = ScoreSkill(skill, userText, queryTokens),
Priority = GetSkillSourcePriority(skill.SourceScope),
})
.Where(x => x.Score > 0)
.OrderByDescending(x => x.Score)
.ThenByDescending(x => x.Priority)
.ThenBy(x => x.Skill.Name, StringComparer.OrdinalIgnoreCase)
.Select(x => x.Skill);
}
@@ -1304,12 +1307,7 @@ public static class SkillService
{
var score = 0;
if (!string.IsNullOrWhiteSpace(skill.Paths) && _activeConditionalSkillNames.Contains(skill.Name))
score += 4;
if (!skill.UserInvocable)
score += 1;
score += Math.Max(0, GetSkillSourcePriority(skill.SourceScope) / 200);
if (skill.DisableModelInvocation)
score += 1;
score += 8;
var haystack = $"{skill.Name} {skill.Label} {skill.Description} {skill.WhenToUse}".ToLowerInvariant();
foreach (var token in queryTokens)
@@ -1320,7 +1318,16 @@ public static class SkillService
if (!string.IsNullOrWhiteSpace(skill.WhenToUse)
&& userText.Contains(skill.WhenToUse, StringComparison.OrdinalIgnoreCase))
score += 3;
score += 4;
// Small boosts are useful only after a real semantic/path match exists.
if (score > 0)
{
if (!skill.UserInvocable)
score += 1;
if (skill.DisableModelInvocation)
score += 1;
}
return score;
}