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; /// /// Composes the system prompt: base prompt, tools section (from ), 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 (), then date, time zone and cwd. File names match case-insensitively. /// public sealed class SystemPromptBuilder { public const int MaxProjectContextBytes = 32 * 1024; private readonly string _cwd; private readonly Func> _activeTools; private readonly TimeProvider _time; private readonly Func _zone; private readonly Func _skillSessionContext; /// The session's working directory. /// The session's active tools, read at every render. /// Clock for the date line; when null. /// The session's zone, read at every render; when null. /// The skills' session-start hook output, read at every render; none when null. public SystemPromptBuilder(string cwd, Func> activeTools, TimeProvider? time = null, Func? zone = null, Func? 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(); } /// /// Date (in ), 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. /// 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}"); } /// /// Concatenates one project context file per directory walking up from cwd (nearest first): the first of /// present. Size capped. /// 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(); } /// Reads a file from cwd (name matched case-insensitively), size capped. Path is null when absent. 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\n"); }