Files
VolcanicAgenticCore/src/Volcanic.AgenticCore/Features/SystemPrompt/SystemPromptBuilder.cs
EugeneTes 644f72068c feat(skills): session-start and turn-end hooks and protected paths declared in SKILL.md
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.
2026-10-16 12:27:44 +00:00

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