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:
EugeneTes
2026-10-12 10:15:05 +00:00
parent dccdf3a8ed
commit bb1c534a7a
9 changed files with 253 additions and 25 deletions

View File

@@ -80,7 +80,9 @@ Read `docs/architecture.md` first. Key points:
- **System prompt** pulls project context from the working directory: `AGENTS.md`, or `CLAUDE.md` if there is no
`AGENTS.md` (never both), plus `PERSONA.md` and `MEMORY.md`. File names match case-insensitively. `SkillCatalog`
lists the cwd's Agent Skills (`.volcanic/skills/<dir>/SKILL.md`, frontmatter `name`/`description`) in a `<skills>`
section on every run; the model reads a SKILL.md with `read`. Only the cwd, so chats never see each other's skills.
section on every run; the model reads a SKILL.md with `read`. A skill dir's optional `INSTRUCTIONS.md` (even without
SKILL.md) goes in full into `<skill_instructions>` on every run (16 KB/file, 64 KB total, truncation noted).
Only the cwd, so chats never see each other's skills.
The Claude Code provider keeps the CLI's own skills off (`--setting-sources ""`, `--disable-slash-commands`).
- **Extension discovery**: host code, then `<cwd>/.volcanic/settings.json` / `.volcanic/extensions/*.dll`, then the same
under `~/.volcanic/agent`. Earlier sources win name collisions. Each assembly loads in its own

View File

@@ -106,7 +106,8 @@ event model; slash commands; provider registration; API key resolution; built-in
Beyond pi: the working directory may also hold `PERSONA.md` (the agent's personality) and `MEMORY.md` (memories
the agent maintains itself with `edit`/`write`); both are appended to the system prompt when present. File names
match case-insensitively. Agent Skills (Anthropic's `SKILL.md` format) in `<cwd>/.volcanic/skills/<name>/SKILL.md` are
listed by name and description in the system prompt on every run; the model reads a skill's file when it needs it.
listed by name and description in the system prompt on every run; the model reads a skill's file when it needs it. An
`INSTRUCTIONS.md` next to it (or alone in the directory) goes into the prompt in full on every run (16 KB per file, 64 KB total).
Not ported (by design): the terminal UI, themes, keybindings, renderers, prompt templates,
OAuth logins, image generation models, session tree navigation commands.

View File

@@ -188,6 +188,16 @@ description and path, re-read on every run, so a skill the agent (or you) just w
on, no `/new` needed. The model reads the full SKILL.md with `read` when a task matches. Skills without a
`description` are skipped. A chat only sees the skills in its own workspace. `/skills` lists them.
A skill directory may also hold an `INSTRUCTIONS.md` (name matched case-insensitively): standing rules that apply all
the time, not only when the skill is used. Its whole body (a leading YAML frontmatter is cut) goes into every system
prompt in a `<skill_instructions>` section after `<skills>`, as `<instructions skill="<name>" location="<path>">`
blocks in directory order. It is re-read on every run, so an edit counts from the next message. The name is the
SKILL.md `name`, else the directory name. A directory may hold only an `INSTRUCTIONS.md`; it is then not listed among
the skills, but its rules still apply. Limits: 16 KB (16,384 characters) per file and 64 KB (65,536) for all
files together; a file over its share is cut and the prompt says `[…truncated, read the full file at <path>]`, and
once the total is used up, later files appear with only that note. Empty and unreadable files are skipped. `/skills`
marks skills with `(+ INSTRUCTIONS.md)` and lists instruction-only directories as `INSTRUCTIONS.md only`.
```markdown
---
name: trip-planner

View File

@@ -226,9 +226,15 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
private static string Skills(ChatContext chat)
{
var skills = SkillCatalog.Load(chat.Session.Cwd);
var instructions = SkillCatalog.LoadInstructions(chat.Session.Cwd);
var where = $"`{Path.GetRelativePath(chat.Session.Cwd, SkillCatalog.Directory(chat.Session.Cwd))}/<name>/SKILL.md`";
if (skills.Count == 0) return $"No skills yet. Ask me to write one, or put it in {where}.";
return $"**Skills** ({where})\n" + string.Join('\n', skills.Select(s => $"• **{s.Name}** – {s.Description}"));
if (skills.Count == 0 && instructions.Count == 0) return $"No skills yet. Ask me to write one, or put it in {where}.";
static string Mark(SkillInstructions i) => i.Truncated ? " (+ INSTRUCTIONS.md, truncated in the prompt)" : " (+ INSTRUCTIONS.md)";
var byDir = instructions.ToDictionary(i => Path.GetDirectoryName(i.Path)!, StringComparer.Ordinal);
var lines = skills.Select(s => $"• **{s.Name}** – {s.Description}" + (byDir.Remove(Path.GetDirectoryName(s.Path)!, out var i) ? Mark(i) : ""))
.Concat(byDir.Values.Select(i => $"• **{i.Name}** – INSTRUCTIONS.md only" + (i.Truncated ? " (truncated in the prompt)" : "")));
return $"**Skills** ({where})\n" + string.Join('\n', lines);
}
private string Jobs(ChatContext chat)

View File

@@ -35,5 +35,14 @@ public static class DefaultPrompts
When a task matches a skill's description, read its SKILL.md with the read tool before you start and follow it.
Resolve relative paths in a skill against the skill's directory.
To create or improve a skill, write or edit its SKILL.md; the list below is re-read on every run.
A skill directory may also hold an INSTRUCTIONS.md (with or without a SKILL.md): rules that apply all the time.
Its body is always in your system prompt (<skill_instructions>), re-read on every run, so you never read it
yourself unless it is marked truncated. Keep it short (it is capped at 16 KB per file, 64 KB in total) and put
task-specific detail in SKILL.md instead.
""";
public const string SkillInstructionsPreamble = """
Standing instructions from the skill directories' INSTRUCTIONS.md files. Follow them at all times, not only
when a skill is in use. Resolve relative paths against the file's directory.
""";
}

View File

@@ -5,36 +5,40 @@ namespace Volcanic.AgenticCore.Features.SystemPrompt;
/// <summary>A skill found in the workspace: frontmatter name and description, and the absolute path of its SKILL.md.</summary>
public sealed record Skill(string Name, string Description, string Path);
/// <summary>
/// The always-on part of a skill directory: the body of its INSTRUCTIONS.md (frontmatter cut), already capped.
/// <paramref name="Truncated"/> when the per-file or the total limit cut it.
/// </summary>
public sealed record SkillInstructions(string Name, string Path, string Content, bool Truncated);
/// <summary>
/// Agent Skills (Anthropic's format) from the working directory only: <c>&lt;cwd&gt;/.volcanic/skills/&lt;dir&gt;/SKILL.md</c>,
/// a YAML frontmatter with <c>name</c> and <c>description</c>, then the instructions. Scanned on every call, so a skill
/// written by the agent shows up in the next run's system prompt. Skills without a description are skipped; the name
/// defaults to the directory name. The model only sees the list and reads a SKILL.md itself (progressive disclosure).
/// A skill directory may also hold an INSTRUCTIONS.md, with or without a SKILL.md: its body goes into every system
/// prompt in full (<see cref="LoadInstructions"/>), up to <see cref="MaxInstructionsLength"/> per file and
/// <see cref="MaxTotalInstructionsLength"/> in total.
/// </summary>
public static class SkillCatalog
{
public const int MaxSkills = 100;
public const int MaxDescriptionLength = 1024;
public const int MaxInstructionsLength = 16 * 1024;
public const int MaxTotalInstructionsLength = 64 * 1024;
public const string SkillFile = "SKILL.md";
public const string InstructionsFile = "INSTRUCTIONS.md";
private const int MaxFrontmatterBytes = 16 * 1024;
public static string Directory(string cwd) => System.IO.Path.Combine(cwd, ".volcanic", "skills");
public static IReadOnlyList<Skill> Load(string cwd)
{
var root = Directory(cwd);
IEnumerable<string> dirs;
try
{
if (!System.IO.Directory.Exists(root)) return [];
dirs = System.IO.Directory.EnumerateDirectories(root).Order(StringComparer.Ordinal).ToList();
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return []; }
var skills = new List<Skill>();
foreach (var dir in dirs)
foreach (var dir in SkillDirectories(cwd))
{
if (skills.Count >= MaxSkills) break;
var file = SystemPromptBuilder.FindFile(dir, "SKILL.md");
var file = SystemPromptBuilder.FindFile(dir, SkillFile);
if (file is null || Parse(file, System.IO.Path.GetFileName(dir)) is not { } skill) continue;
if (skills.Any(s => string.Equals(s.Name, skill.Name, StringComparison.OrdinalIgnoreCase))) continue;
skills.Add(skill);
@@ -42,18 +46,37 @@ public static class SkillCatalog
return skills;
}
/// <summary>
/// The INSTRUCTIONS.md of every skill directory, in directory order, read fresh on every call. Empty and unreadable
/// files are skipped. The name is the SKILL.md frontmatter <c>name</c> when there is one, else the directory name.
/// Each body is capped at <see cref="MaxInstructionsLength"/>; once <see cref="MaxTotalInstructionsLength"/> is used
/// up, the next file is cut to what is left and later ones come with an empty body, all marked truncated.
/// </summary>
public static IReadOnlyList<SkillInstructions> LoadInstructions(string cwd)
{
var result = new List<SkillInstructions>();
var budget = MaxTotalInstructionsLength;
foreach (var dir in SkillDirectories(cwd))
{
if (result.Count >= MaxSkills) break;
var file = SystemPromptBuilder.FindFile(dir, InstructionsFile);
if (file is null || ReadPrefix(file, MaxFrontmatterBytes + MaxInstructionsLength + 1) is not { } text) continue;
var body = StripFrontmatter(text).Trim();
if (body.Length == 0) continue;
var limit = Math.Min(MaxInstructionsLength, budget);
var truncated = body.Length > limit;
if (truncated) body = body[..limit].TrimEnd();
budget -= body.Length;
result.Add(new SkillInstructions(NameOf(dir), file, body, truncated));
}
return result;
}
/// <summary>Reads the frontmatter of one SKILL.md. Null when it is unreadable or has no description.</summary>
public static Skill? Parse(string path, string fallbackName)
{
string text;
try
{
using var reader = new StreamReader(path);
var buffer = new char[MaxFrontmatterBytes];
text = new string(buffer, 0, reader.ReadBlock(buffer, 0, buffer.Length));
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return null; }
if (ReadPrefix(path, MaxFrontmatterBytes) is not { } text) return null;
var fields = Frontmatter(text);
var name = fields.GetValueOrDefault("name");
var description = fields.GetValueOrDefault("description");
@@ -78,6 +101,66 @@ public static class SkillCatalog
return sb.Append("\n</available_skills>").ToString();
}
/// <summary>The always-on section: one <c>&lt;instructions&gt;</c> block per file. Empty when there are none.</summary>
public static string RenderInstructions(IReadOnlyList<SkillInstructions> instructions)
{
if (instructions.Count == 0) return "";
var sb = new StringBuilder(DefaultPrompts.SkillInstructionsPreamble);
foreach (var item in instructions)
{
sb.Append("\n\n<instructions skill=\"").Append(EscapeAttribute(item.Name)).Append("\" location=\"")
.Append(EscapeAttribute(item.Path)).Append("\">\n");
if (item.Content.Length > 0) sb.Append(item.Content).Append('\n');
if (item.Truncated) sb.Append("[…truncated, read the full file at ").Append(item.Path).Append("]\n");
sb.Append("</instructions>");
}
return sb.ToString();
}
/// <summary>Text after a leading <c>---</c> frontmatter block; the whole text when there is none or it never closes.</summary>
internal static string StripFrontmatter(string text)
{
var normalized = text.Replace("\r\n", "\n");
if (normalized.StartsWith('\uFEFF')) normalized = normalized[1..];
var lines = normalized.Split('\n');
if (lines[0].Trim() != "---") return normalized;
for (var i = 1; i < lines.Length; i++)
{
if (lines[i].Trim() == "---") return string.Join('\n', lines[(i + 1)..]);
}
return normalized;
}
private static IReadOnlyList<string> SkillDirectories(string cwd)
{
var root = Directory(cwd);
try
{
return System.IO.Directory.Exists(root) ? System.IO.Directory.EnumerateDirectories(root).Order(StringComparer.Ordinal).ToList() : [];
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return []; }
}
/// <summary>The SKILL.md frontmatter <c>name</c>, with or without a description, else the directory name.</summary>
private static string NameOf(string dir)
{
var fallback = System.IO.Path.GetFileName(dir);
var file = SystemPromptBuilder.FindFile(dir, SkillFile);
var name = file is null || ReadPrefix(file, MaxFrontmatterBytes) is not { } text ? null : Frontmatter(text).GetValueOrDefault("name");
return string.IsNullOrWhiteSpace(name) ? fallback : Collapse(name);
}
private static string? ReadPrefix(string path, int maxChars)
{
try
{
using var reader = new StreamReader(path);
var buffer = new char[maxChars];
return new string(buffer, 0, reader.ReadBlock(buffer, 0, buffer.Length));
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return null; }
}
/// <summary>
/// Top-level <c>key: value</c> pairs between the leading <c>---</c> lines. Handles quoted values, <c>|</c>/<c>&gt;</c>
/// block scalars and indented continuation lines; anything else YAML can do is ignored.
@@ -123,4 +206,6 @@ public static class SkillCatalog
private static string Collapse(string value) => string.Join(' ', value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
private static string Escape(string value) => value.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
private static string EscapeAttribute(string value) => Escape(value).Replace("\"", "&quot;");
}

View File

@@ -9,7 +9,7 @@ namespace Volcanic.AgenticCore.Features.SystemPrompt;
/// <summary>
/// Composes the system prompt: base prompt, tools section (from <see cref="ITool.PromptSnippet"/>), guidelines,
/// extra sections, project context files (AGENTS.md, else CLAUDE.md, per directory walking up from cwd), the agent's
/// persona (PERSONA.md in cwd), its memory (MEMORY.md in cwd), its skills (<see cref="SkillCatalog"/>), then date, time zone and cwd. File names match case-insensitively.
/// persona (PERSONA.md in cwd), its memory (MEMORY.md in cwd), its skills and their always-on INSTRUCTIONS.md (<see cref="SkillCatalog"/>), then date, time zone and cwd. File names match case-insensitively.
/// </summary>
public sealed class SystemPromptBuilder
{
@@ -70,6 +70,9 @@ public sealed class SystemPromptBuilder
if (memory.Path is not null) AppendSection(sb, "memory", DefaultPrompts.MemoryPreamble(memory.Path) + (string.IsNullOrWhiteSpace(memory.Content) ? "" : "\n\n" + memory.Content));
AppendSection(sb, "skills", SkillCatalog.Render(_cwd, SkillCatalog.Load(_cwd)));
var instructions = SkillCatalog.RenderInstructions(SkillCatalog.LoadInstructions(_cwd));
if (instructions.Length > 0) AppendSection(sb, "skill_instructions", instructions);
}
AppendSection(sb, "environment", EnvironmentSection(_time.GetUtcNow(), _zone(), _cwd));

View File

@@ -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);
}
}

View File

@@ -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 \\&quot;rules\\&quot;\"", 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