A skill's SKILL.md frontmatter may declare session-start (output into <skill_session_context>, at session start, /new, resume and after compaction), turn-end (after every run, in the background; output or a failure reaches the next run as a hidden notice, repeats suppressed until a clean run) and protected-paths (write/edit refused inside them). Hooks run from the skill directory with VOLCANIC_WORKSPACE, VOLCANIC_SKILL_DIR and the chat's TZ; waits are bounded by each hook's timeout and failures never break a run. /skills shows declared hooks and broken fields.
151 lines
7.5 KiB
C#
151 lines
7.5 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
|
|
using Volcanic.AgenticCore.Abstractions.Tools;
|
|
using Volcanic.AgenticCore.Hosting;
|
|
|
|
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 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
|
|
{
|
|
public const int MaxProjectContextBytes = 32 * 1024;
|
|
|
|
private readonly string _cwd;
|
|
private readonly Func<IReadOnlyList<ITool>> _activeTools;
|
|
private readonly TimeProvider _time;
|
|
private readonly Func<TimeZoneInfo> _zone;
|
|
private readonly Func<string?> _skillSessionContext;
|
|
|
|
/// <param name="cwd">The session's working directory.</param>
|
|
/// <param name="activeTools">The session's active tools, read at every render.</param>
|
|
/// <param name="time">Clock for the date line; <see cref="TimeProvider.System"/> when null.</param>
|
|
/// <param name="zone">The session's zone, read at every render; <see cref="TimeZoneInfo.Local"/> when null.</param>
|
|
/// <param name="skillSessionContext">The skills' session-start hook output, read at every render; none when null.</param>
|
|
public SystemPromptBuilder(string cwd, Func<IReadOnlyList<ITool>> activeTools, TimeProvider? time = null, Func<TimeZoneInfo>? zone = null,
|
|
Func<string?>? skillSessionContext = null)
|
|
{
|
|
_skillSessionContext = skillSessionContext ?? (() => null);
|
|
_cwd = cwd;
|
|
_activeTools = activeTools;
|
|
_time = time ?? TimeProvider.System;
|
|
_zone = zone ?? (() => TimeZoneInfo.Local);
|
|
}
|
|
|
|
public SystemPromptOptions DefaultOptions(string? basePrompt)
|
|
{
|
|
var options = new SystemPromptOptions { BasePrompt = basePrompt ?? DefaultPrompts.BasePrompt };
|
|
options.SelectedTools.AddRange(_activeTools().Select(t => t.Name));
|
|
options.Guidelines.AddRange(DefaultPrompts.BaseGuidelines);
|
|
return options;
|
|
}
|
|
|
|
public string Render(SystemPromptOptions options)
|
|
{
|
|
var tools = _activeTools().Where(t => options.SelectedTools.Count == 0 || options.SelectedTools.Contains(t.Name)).ToList();
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine((options.BasePrompt ?? DefaultPrompts.BasePrompt).Trim());
|
|
|
|
var snippets = tools.Where(t => !string.IsNullOrWhiteSpace(t.PromptSnippet)).Select(t => $"- {t.PromptSnippet}").ToList();
|
|
if (snippets.Count > 0) AppendSection(sb, "tools", "Available tools:\n" + string.Join('\n', snippets));
|
|
|
|
var guidelines = options.Guidelines.Concat(tools.SelectMany(t => t.PromptGuidelines)).Distinct().Select(g => $"- {g}").ToList();
|
|
if (guidelines.Count > 0) AppendSection(sb, "guidelines", string.Join('\n', guidelines));
|
|
|
|
foreach (var (name, content) in options.Sections)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(content)) AppendSection(sb, name, content.Trim());
|
|
}
|
|
|
|
if (options.IncludeProjectContext)
|
|
{
|
|
var context = LoadProjectContext();
|
|
if (context.Length > 0) AppendSection(sb, "project_context", context);
|
|
|
|
var persona = ReadWorkspaceFile(DefaultPrompts.PersonaFile);
|
|
if (!string.IsNullOrWhiteSpace(persona.Content)) AppendSection(sb, "persona", persona.Content);
|
|
|
|
// The memory section appears whenever the file exists, even empty, so the agent knows where to write.
|
|
var memory = ReadWorkspaceFile(DefaultPrompts.MemoryFile);
|
|
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);
|
|
|
|
if (_skillSessionContext() is { Length: > 0 } sessionContext) AppendSection(sb, "skill_session_context", sessionContext);
|
|
}
|
|
|
|
AppendSection(sb, "environment", EnvironmentSection(_time.GetUtcNow(), _zone(), _cwd));
|
|
return sb.ToString().TrimEnd();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Date (in <paramref name="zone"/>), zone and working directory. No time of day: the prompt is rendered per run and
|
|
/// a clock time would change the cached prefix every run; the model gets the time from the timestamp header instead.
|
|
/// </summary>
|
|
public static string EnvironmentSection(DateTimeOffset utcNow, TimeZoneInfo zone, string cwd)
|
|
{
|
|
var local = TimeZoneInfo.ConvertTime(utcNow, zone);
|
|
return string.Create(CultureInfo.InvariantCulture,
|
|
$"Current date: {local:yyyy-MM-dd} ({local:dddd})\nTime zone: {TimeZoneIds.Name(zone)} ({TimeZoneIds.FormatOffset(local.Offset)})\nWorking directory: {cwd}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Concatenates one project context file per directory walking up from cwd (nearest first): the first of
|
|
/// <see cref="DefaultPrompts.ProjectContextFiles"/> present. Size capped.
|
|
/// </summary>
|
|
public string LoadProjectContext()
|
|
{
|
|
var sb = new StringBuilder();
|
|
var dir = new DirectoryInfo(_cwd);
|
|
while (dir is not null && sb.Length < MaxProjectContextBytes)
|
|
{
|
|
var path = DefaultPrompts.ProjectContextFiles.Select(name => FindFile(dir.FullName, name)).FirstOrDefault(p => p is not null);
|
|
var content = path is null ? null : TryRead(path, MaxProjectContextBytes - sb.Length);
|
|
if (content is not null)
|
|
{
|
|
sb.Append("# ").Append(Path.GetRelativePath(_cwd, path!)).Append('\n').Append(content).Append("\n\n");
|
|
}
|
|
dir = dir.Parent;
|
|
}
|
|
return sb.ToString().TrimEnd();
|
|
}
|
|
|
|
/// <summary>Reads a file from cwd (name matched case-insensitively), size capped. Path is null when absent.</summary>
|
|
public (string? Path, string? Content) ReadWorkspaceFile(string name)
|
|
{
|
|
var path = FindFile(_cwd, name);
|
|
return path is null ? (null, null) : (path, TryRead(path, MaxProjectContextBytes));
|
|
}
|
|
|
|
internal static string? FindFile(string dir, string name)
|
|
{
|
|
var exact = Path.Combine(dir, name);
|
|
if (File.Exists(exact)) return exact;
|
|
try
|
|
{
|
|
return Directory.EnumerateFiles(dir).FirstOrDefault(f => string.Equals(Path.GetFileName(f), name, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return null; }
|
|
}
|
|
|
|
private static string? TryRead(string path, int maxLength)
|
|
{
|
|
if (maxLength <= 0) return null;
|
|
string content;
|
|
try { content = File.ReadAllText(path); }
|
|
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return null; }
|
|
if (content.Length > maxLength) content = content[..maxLength] + "\n[truncated]";
|
|
return content.Trim();
|
|
}
|
|
|
|
private static void AppendSection(StringBuilder sb, string name, string content) =>
|
|
sb.Append('\n').Append('<').Append(name).Append(">\n").Append(content).Append("\n</").Append(name).Append(">\n");
|
|
}
|