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").Append(name).Append(">\n");
}