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:
@@ -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
|
- **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`
|
`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>`
|
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`).
|
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
|
- **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
|
under `~/.volcanic/agent`. Earlier sources win name collisions. Each assembly loads in its own
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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,
|
Not ported (by design): the terminal UI, themes, keybindings, renderers, prompt templates,
|
||||||
OAuth logins, image generation models, session tree navigation commands.
|
OAuth logins, image generation models, session tree navigation commands.
|
||||||
|
|||||||
@@ -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
|
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.
|
`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
|
```markdown
|
||||||
---
|
---
|
||||||
name: trip-planner
|
name: trip-planner
|
||||||
|
|||||||
@@ -226,9 +226,15 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
|
|||||||
private static string Skills(ChatContext chat)
|
private static string Skills(ChatContext chat)
|
||||||
{
|
{
|
||||||
var skills = SkillCatalog.Load(chat.Session.Cwd);
|
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`";
|
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}.";
|
if (skills.Count == 0 && instructions.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}"));
|
|
||||||
|
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)
|
private string Jobs(ChatContext chat)
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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.
|
||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
/// <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);
|
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>
|
/// <summary>
|
||||||
/// Agent Skills (Anthropic's format) from the working directory only: <c><cwd>/.volcanic/skills/<dir>/SKILL.md</c>,
|
/// Agent Skills (Anthropic's format) from the working directory only: <c><cwd>/.volcanic/skills/<dir>/SKILL.md</c>,
|
||||||
/// a YAML frontmatter with <c>name</c> and <c>description</c>, then the instructions. Scanned on every call, so a skill
|
/// 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
|
/// 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).
|
/// 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>
|
/// </summary>
|
||||||
public static class SkillCatalog
|
public static class SkillCatalog
|
||||||
{
|
{
|
||||||
public const int MaxSkills = 100;
|
public const int MaxSkills = 100;
|
||||||
public const int MaxDescriptionLength = 1024;
|
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;
|
private const int MaxFrontmatterBytes = 16 * 1024;
|
||||||
|
|
||||||
public static string Directory(string cwd) => System.IO.Path.Combine(cwd, ".volcanic", "skills");
|
public static string Directory(string cwd) => System.IO.Path.Combine(cwd, ".volcanic", "skills");
|
||||||
|
|
||||||
public static IReadOnlyList<Skill> Load(string cwd)
|
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>();
|
var skills = new List<Skill>();
|
||||||
foreach (var dir in dirs)
|
foreach (var dir in SkillDirectories(cwd))
|
||||||
{
|
{
|
||||||
if (skills.Count >= MaxSkills) break;
|
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 (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;
|
if (skills.Any(s => string.Equals(s.Name, skill.Name, StringComparison.OrdinalIgnoreCase))) continue;
|
||||||
skills.Add(skill);
|
skills.Add(skill);
|
||||||
@@ -42,18 +46,37 @@ public static class SkillCatalog
|
|||||||
return skills;
|
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>
|
/// <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)
|
public static Skill? Parse(string path, string fallbackName)
|
||||||
{
|
{
|
||||||
string text;
|
if (ReadPrefix(path, MaxFrontmatterBytes) is not { } text) return null;
|
||||||
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; }
|
|
||||||
|
|
||||||
var fields = Frontmatter(text);
|
var fields = Frontmatter(text);
|
||||||
var name = fields.GetValueOrDefault("name");
|
var name = fields.GetValueOrDefault("name");
|
||||||
var description = fields.GetValueOrDefault("description");
|
var description = fields.GetValueOrDefault("description");
|
||||||
@@ -78,6 +101,66 @@ public static class SkillCatalog
|
|||||||
return sb.Append("\n</available_skills>").ToString();
|
return sb.Append("\n</available_skills>").ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The always-on section: one <c><instructions></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>
|
/// <summary>
|
||||||
/// Top-level <c>key: value</c> pairs between the leading <c>---</c> lines. Handles quoted values, <c>|</c>/<c>></c>
|
/// Top-level <c>key: value</c> pairs between the leading <c>---</c> lines. Handles quoted values, <c>|</c>/<c>></c>
|
||||||
/// block scalars and indented continuation lines; anything else YAML can do is ignored.
|
/// 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 Collapse(string value) => string.Join(' ', value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
|
||||||
private static string Escape(string value) => value.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
private static string Escape(string value) => value.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||||
|
|
||||||
|
private static string EscapeAttribute(string value) => Escape(value).Replace("\"", """);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace Volcanic.AgenticCore.Features.SystemPrompt;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Composes the system prompt: base prompt, tools section (from <see cref="ITool.PromptSnippet"/>), guidelines,
|
/// 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
|
/// 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>
|
/// </summary>
|
||||||
public sealed class SystemPromptBuilder
|
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));
|
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)));
|
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));
|
AppendSection(sb, "environment", EnvironmentSection(_time.GetUtcNow(), _zone(), _cwd));
|
||||||
|
|||||||
@@ -45,4 +45,26 @@ public sealed class SkillsChatTests
|
|||||||
Assert.Contains(Path.Combine(host.WorkspaceOf(), ".volcanic", "skills", "trips", "SKILL.md"), prompt);
|
Assert.Contains(Path.Combine(host.WorkspaceOf(), ".volcanic", "skills", "trips", "SKILL.md"), prompt);
|
||||||
Assert.DoesNotContain("other-chat", 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); }
|
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
|
public class CompactorTests
|
||||||
|
|||||||
Reference in New Issue
Block a user