feat(core): always-on INSTRUCTIONS.md in skill directories
A skill directory may hold INSTRUCTIONS.md, with or without SKILL.md. Its body (frontmatter cut) goes into every system prompt in <skill_instructions>, re-read on every run: 16 KB per file, 64 KB in total, truncation marked with the file's path. /skills marks skills with instructions and lists instruction-only dirs.
This commit is contained in:
@@ -45,4 +45,26 @@ public sealed class SkillsChatTests
|
||||
Assert.Contains(Path.Combine(host.WorkspaceOf(), ".volcanic", "skills", "trips", "SKILL.md"), prompt);
|
||||
Assert.DoesNotContain("other-chat", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skills_marks_instructions_and_lists_instruction_only_folders()
|
||||
{
|
||||
await using var host = new ChatHost();
|
||||
var root = Path.Combine(host.WorkspaceOf(), ".volcanic", "skills");
|
||||
WriteSkill(host.WorkspaceOf(), "trips", "trip-planner", "Plans trips");
|
||||
WriteSkill(host.WorkspaceOf(), "mail", "mailer", "Sends mail");
|
||||
File.WriteAllText(Path.Combine(root, "trips", "INSTRUCTIONS.md"), "Prices in EUR.");
|
||||
Directory.CreateDirectory(Path.Combine(root, "house-rules"));
|
||||
File.WriteAllText(Path.Combine(root, "house-rules", "Instructions.md"), "Be brief.");
|
||||
host.Faux.Enqueue(FauxTurn.Say("ok"));
|
||||
|
||||
await host.SendAsync("/skills");
|
||||
Assert.Equal("Skills (.volcanic/skills/<name>/SKILL.md)\n• mailer – Sends mail\n• trip-planner – Plans trips (+ INSTRUCTIONS.md)\n• house-rules – INSTRUCTIONS.md only", host.LastReply());
|
||||
|
||||
await host.SendAsync("hi");
|
||||
await host.SettleAsync();
|
||||
var prompt = host.Faux.Requests.Last().SystemPrompt!;
|
||||
Assert.Contains("<instructions skill=\"house-rules\"", prompt);
|
||||
Assert.Contains("Prices in EUR.", prompt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,96 @@ public class SkillCatalogTests
|
||||
}
|
||||
finally { Directory.Delete(dir, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Instructions_go_into_every_prompt_with_or_without_a_skill_md()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "volcanic-prompt", Guid.NewGuid().ToString("N"));
|
||||
var skills = Path.Combine(dir, ".volcanic", "skills");
|
||||
foreach (var name in new[] { "b-trips", "a-rules", "c-empty", "d-nameless" }) Directory.CreateDirectory(Path.Combine(skills, name));
|
||||
File.WriteAllText(Path.Combine(skills, "b-trips", "SKILL.md"), "---\nname: trip-planner\ndescription: Plans trips\n---\nLazy body");
|
||||
File.WriteAllText(Path.Combine(skills, "b-trips", "instructions.md"), "---\ntitle: ignored\n---\nAlways quote prices in EUR.\n");
|
||||
File.WriteAllText(Path.Combine(skills, "a-rules", "INSTRUCTIONS.md"), "Never book before 9:00.");
|
||||
File.WriteAllText(Path.Combine(skills, "c-empty", "INSTRUCTIONS.md"), "---\nx: y\n---\n \n");
|
||||
File.WriteAllText(Path.Combine(skills, "d-nameless", "SKILL.md"), "---\nname: \"named \\\"rules\\\"\"\n---\n");
|
||||
File.WriteAllText(Path.Combine(skills, "d-nameless", "INSTRUCTIONS.md"), "Use <b>tags</b> as is.");
|
||||
try
|
||||
{
|
||||
var builder = new SystemPromptBuilder(dir, () => [], new FakeTimeProvider(DateTimeOffset.UnixEpoch), () => TimeZoneInfo.Utc);
|
||||
var prompt = builder.Render(builder.DefaultOptions("BASE"));
|
||||
|
||||
Assert.Equal(["a-rules", "trip-planner", "named \\\"rules\\\""], SkillCatalog.LoadInstructions(dir).Select(i => i.Name));
|
||||
Assert.Contains($"<instructions skill=\"a-rules\" location=\"{Path.Combine(skills, "a-rules", "INSTRUCTIONS.md")}\">\nNever book before 9:00.\n</instructions>", prompt);
|
||||
Assert.Contains($"<instructions skill=\"trip-planner\" location=\"{Path.Combine(skills, "b-trips", "instructions.md")}\">\nAlways quote prices in EUR.\n</instructions>", prompt);
|
||||
Assert.Contains("skill=\"named \\"rules\\"\"", prompt);
|
||||
Assert.Contains("Use <b>tags</b> as is.", prompt);
|
||||
Assert.DoesNotContain("ignored", prompt);
|
||||
Assert.DoesNotContain("c-empty", prompt);
|
||||
Assert.DoesNotContain("Lazy body", prompt);
|
||||
Assert.Equal(["trip-planner"], SkillCatalog.Load(dir).Select(s => s.Name));
|
||||
var order = new[] { "\n<skills>\n", "\n<skill_instructions>\n", "\n<environment>\n" }.Select(t => prompt.IndexOf(t, StringComparison.Ordinal)).ToList();
|
||||
Assert.Equal(order.Order(), order);
|
||||
Assert.True(order[0] >= 0);
|
||||
|
||||
File.WriteAllText(Path.Combine(skills, "a-rules", "INSTRUCTIONS.md"), "Never book before 10:00.");
|
||||
Assert.Contains("Never book before 10:00.", builder.Render(builder.DefaultOptions("BASE")));
|
||||
|
||||
var narrow = builder.DefaultOptions("BASE");
|
||||
narrow.IncludeProjectContext = false;
|
||||
Assert.DoesNotContain("<skill_instructions>", builder.Render(narrow));
|
||||
}
|
||||
finally { Directory.Delete(dir, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_instructions_there_is_no_instructions_section()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "volcanic-prompt", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var builder = new SystemPromptBuilder(dir, () => [], new FakeTimeProvider(DateTimeOffset.UnixEpoch), () => TimeZoneInfo.Utc);
|
||||
Assert.DoesNotContain("\n<skill_instructions>\n", builder.Render(builder.DefaultOptions("BASE")));
|
||||
}
|
||||
finally { Directory.Delete(dir, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Instructions_are_capped_per_file_and_in_total_with_a_pointer_to_the_file()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "volcanic-prompt", Guid.NewGuid().ToString("N"));
|
||||
var skills = Path.Combine(dir, ".volcanic", "skills");
|
||||
var per = SkillCatalog.MaxInstructionsLength;
|
||||
// 3 full files use 48 KB, the fourth is cut by its own cap, the fifth gets nothing.
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(skills, $"s{i}"));
|
||||
File.WriteAllText(Path.Combine(skills, $"s{i}", "INSTRUCTIONS.md"), new string((char)('a' + i), i == 3 ? per + 500 : per));
|
||||
}
|
||||
Directory.CreateDirectory(Path.Combine(skills, "s5"));
|
||||
File.WriteAllText(Path.Combine(skills, "s5", "INSTRUCTIONS.md"), "late");
|
||||
try
|
||||
{
|
||||
var loaded = SkillCatalog.LoadInstructions(dir);
|
||||
Assert.Equal([per, per, per, per, 0, 0], loaded.Select(i => i.Content.Length));
|
||||
Assert.Equal([false, false, false, true, true, true], loaded.Select(i => i.Truncated));
|
||||
Assert.Equal(SkillCatalog.MaxTotalInstructionsLength, loaded.Sum(i => i.Content.Length));
|
||||
|
||||
var section = SkillCatalog.RenderInstructions(loaded);
|
||||
Assert.Contains($"[…truncated, read the full file at {Path.Combine(skills, "s3", "INSTRUCTIONS.md")}]", section);
|
||||
Assert.Contains($"location=\"{Path.Combine(skills, "s5", "INSTRUCTIONS.md")}\">\n[…truncated, read the full file at {Path.Combine(skills, "s5", "INSTRUCTIONS.md")}]\n</instructions>", section);
|
||||
Assert.DoesNotContain("late", section);
|
||||
}
|
||||
finally { Directory.Delete(dir, true); }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("---\na: b\n---\nbody", "body")]
|
||||
[InlineData("\uFEFF---\r\na: b\r\n---\r\nbody", "body")]
|
||||
[InlineData("---\nnever closed\nbody", "---\nnever closed\nbody")]
|
||||
[InlineData("plain\n---\nrule", "plain\n---\nrule")]
|
||||
public void StripFrontmatter_cuts_only_a_leading_closed_block(string text, string expected) =>
|
||||
Assert.Equal(expected, SkillCatalog.StripFrontmatter(text));
|
||||
}
|
||||
|
||||
public class CompactorTests
|
||||
|
||||
Reference in New Issue
Block a user