Files
VolcanicAgenticCore/src/Volcanic.AgenticCore/Features/SkillHooks/SkillHookRunner.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

330 lines
15 KiB
C#

using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Abstractions.Skills;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Features.SkillHooks;
/// <summary>What a run gets from the skill hooks: the <c>&lt;skill_session_context&gt;</c> body and a turn-end notice, each possibly empty.</summary>
internal sealed record SkillHookRunInput(string SessionContext, CustomMessage? Notice);
/// <summary>
/// The skill hooks of one session (<see cref="SkillHookFiles"/>). Never throws into the loop; every failure ends up in
/// the text the model sees, or in the log.
/// <list type="bullet">
/// <item><c>session-start</c>: started in the background when the session starts (<see cref="SessionStarted"/>: startup,
/// <c>/new</c>, resume, after compaction) and on a run that finds a hook it hasn't run yet. A run waits for running hooks at
/// most until their deadline (start + timeout), then renders what it has; the output is kept until the next start.</item>
/// <item><c>turn-end</c>: started in the background when a run ends (<see cref="RunEnded"/>), never delaying it. The next
/// run waits for it at most until its deadline; output or a failure becomes a notice in that run, unless it is the
/// same notice as last time (repeats stay quiet until a clean run).</item>
/// <item><c>protected-paths</c>: <see cref="CheckToolCall"/> refuses <c>write</c>/<c>edit</c> inside them.</item>
/// </list>
/// </summary>
internal sealed class SkillHookRunner : IDisposable
{
public const string NoticeType = "skill_hook";
public const int MaxSessionContextPerSkill = 16 * 1024;
public const int MaxSessionContextTotal = 32 * 1024;
public const int MaxNoticePerSkill = 8 * 1024;
private readonly string _cwd;
private readonly Func<ProcessSpawnRequest, CancellationToken, ValueTask<ProcessSpawnContext>> _prepare;
private readonly ISecretRedactor _redactor;
private readonly TimeProvider _time;
private readonly Func<TimeZoneInfo> _zone;
private readonly ILogger _logger;
private readonly CancellationTokenSource _disposed = new();
private readonly Lock _lock = new();
private CancellationTokenSource _generation = new();
private List<StartedHook> _sessionStart = [];
private TurnEndRun? _turnEnd;
private readonly Dictionary<string, string> _lastNotice = new(StringComparer.Ordinal);
private IReadOnlyList<SkillHookSet> _hooks = [];
public SkillHookRunner(string cwd, Func<ProcessSpawnRequest, CancellationToken, ValueTask<ProcessSpawnContext>> prepare,
ISecretRedactor redactor, TimeProvider time, Func<TimeZoneInfo> zone, ILogger logger)
{
_cwd = cwd;
_prepare = prepare;
_redactor = redactor;
_time = time;
_zone = zone;
_logger = logger;
}
/// <summary>The hooks the last run read; <see cref="CheckToolCall"/> uses them.</summary>
public IReadOnlyList<SkillHookSet> Current { get { lock (_lock) return _hooks; } }
/// <summary>A session (re)started or was compacted: forget the session-start output and run the hooks again, in the background.</summary>
public void SessionStarted()
{
if (_disposed.IsCancellationRequested) return;
var hooks = Read();
lock (_lock)
{
// Cancelled, not disposed: the old hooks may still be reading the token.
_generation.Cancel();
_generation = CancellationTokenSource.CreateLinkedTokenSource(_disposed.Token);
_sessionStart = [];
_hooks = hooks;
StartMissingSessionHooks(hooks);
}
}
/// <summary>
/// Before a run: re-read the declarations, start session-start hooks not run yet, wait for running ones and the
/// previous turn-end (each at most until its deadline), and return what the run gets.
/// </summary>
public async Task<SkillHookRunInput> BeforeRunAsync(CancellationToken abort)
{
var hooks = Read();
List<StartedHook> started;
TurnEndRun? turnEnd;
lock (_lock)
{
_hooks = hooks;
StartMissingSessionHooks(hooks);
_sessionStart.RemoveAll(h => !hooks.Any(s => Key(s, s.SessionStart) == h.Key));
started = [.. _sessionStart];
turnEnd = _turnEnd;
}
var waits = started.Select(h => ((Task)h.Task, h.Deadline)).ToList();
if (turnEnd is not null) waits.Add((turnEnd.Task, turnEnd.Deadline));
await WaitUntilDeadlinesAsync(waits, abort).ConfigureAwait(false);
CustomMessage? notice = null;
if (turnEnd is { Task.IsCompleted: true })
{
lock (_lock) if (_turnEnd == turnEnd) _turnEnd = null;
notice = Notice(turnEnd.Task.Result);
}
return new SkillHookRunInput(RenderSessionContext(started), notice);
}
/// <summary>A run ended: start the turn-end hooks in the background, unless the previous ones are still running.</summary>
public void RunEnded()
{
if (_disposed.IsCancellationRequested) return;
var hooks = Read().Where(h => h.TurnEnd is not null).ToList();
if (hooks.Count == 0) return;
lock (_lock)
{
if (_turnEnd is { Task.IsCompleted: false })
{
_logger.LogDebug("Skill turn-end hooks are still running; this run's are skipped");
return;
}
// A finished result no run has picked up yet is replaced: the newer check supersedes it.
var deadline = _time.GetUtcNow() + hooks.Max(h => h.TurnEnd!.Timeout);
var token = _disposed.Token;
var task = Task.WhenAll(hooks.Select(h => RunTurnEndAsync(h, token)));
_turnEnd = new TurnEndRun(task, deadline);
}
}
/// <summary>The refusal for a <c>write</c>/<c>edit</c> into a protected path, or null.</summary>
public string? CheckToolCall(string toolName, JsonElement args)
{
if (!ProtectedPathGuard.GuardedTools.Contains(toolName)) return null;
try { return ProtectedPathGuard.Check(_cwd, toolName, args, Read()); }
catch (Exception ex)
{
_logger.LogWarning(ex, "Checking protected paths failed for {Tool}", toolName);
return null;
}
}
public void Dispose()
{
// Cancelled, not disposed: running hooks may still be reading the token.
_disposed.Cancel();
}
// ---- session-start ----
private void StartMissingSessionHooks(IReadOnlyList<SkillHookSet> hooks)
{
foreach (var set in hooks)
{
if (set.SessionStart is not { } command) continue;
var key = Key(set, command);
if (_sessionStart.Any(h => h.Key == key)) continue;
var startedAt = _time.GetUtcNow();
var task = RunAsync(set, command, SkillHookFiles.SessionStartKey, _generation.Token);
_sessionStart.Add(new StartedHook(key, set, command, startedAt, startedAt + command.Timeout, task));
}
}
private string RenderSessionContext(IReadOnlyList<StartedHook> hooks)
{
if (hooks.Count == 0) return "";
var zone = _zone();
var sb = new StringBuilder(DefaultHookPrompts.SessionContextPreamble);
var budget = MaxSessionContextTotal;
foreach (var hook in hooks)
{
var local = TimeZoneInfo.ConvertTime(hook.StartedAt, zone);
var at = string.Create(CultureInfo.InvariantCulture, $"{local:yyyy-MM-dd HH:mm} ({TimeZoneIds.Name(zone)})");
string body;
if (!hook.Task.IsCompleted) body = $"[session-start hook still running after {(int)hook.Command.Timeout.TotalSeconds} s; run the command yourself if you need its output]";
else
{
var outcome = hook.Task.Result;
var parts = new List<string>();
if (outcome.Stdout.Trim().Length > 0) parts.Add(outcome.Stdout.Trim());
if (!outcome.Succeeded)
{
parts.Add($"[session-start hook failed: {outcome.Status(hook.Command.Timeout)}]");
if (outcome.Stderr.Length > 0) parts.Add(outcome.Stderr);
}
body = parts.Count == 0 ? "(no output)" : string.Join('\n', parts);
}
var limit = Math.Min(MaxSessionContextPerSkill, Math.Max(budget, 0));
if (body.Length > limit) body = body[..limit].TrimEnd() + "\n[…truncated]";
budget -= body.Length;
sb.Append("\n\n<context skill=\"").Append(Attribute(hook.Set.Skill)).Append("\" command=\"").Append(Attribute(hook.Command.Command))
.Append("\" at=\"").Append(at).Append("\">\n").Append(body).Append("\n</context>");
}
return sb.ToString();
}
// ---- turn-end ----
private async Task<TurnEndResult> RunTurnEndAsync(SkillHookSet set, CancellationToken cancellationToken)
{
var outcome = await RunAsync(set, set.TurnEnd!, SkillHookFiles.TurnEndKey, cancellationToken).ConfigureAwait(false);
return new TurnEndResult(set, set.TurnEnd!, outcome);
}
private CustomMessage? Notice(IReadOnlyList<TurnEndResult> results)
{
var blocks = new List<string>();
lock (_lock)
{
foreach (var result in results)
{
var key = result.Set.Directory;
if (result.Outcome.IsClean)
{
_lastNotice.Remove(key);
continue;
}
var text = NoticeText(result);
if (_lastNotice.TryGetValue(key, out var last) && last == text) continue;
_lastNotice[key] = text;
blocks.Add(text);
}
}
return blocks.Count == 0 ? null : CustomMessage.FromText(NoticeType, string.Join("\n\n", blocks), display: false);
}
private static string NoticeText(TurnEndResult result)
{
var outcome = result.Outcome;
var sb = new StringBuilder()
.Append("[Skill hook \"").Append(result.Set.Skill).Append("\" turn-end (`").Append(result.Command.Command).Append("`, ")
.Append(outcome.Status(result.Command.Timeout)).Append(") after your previous turn:]");
var body = new List<string>();
if (outcome.Stdout.Trim().Length > 0) body.Add(outcome.Stdout.Trim());
if (!outcome.Succeeded && outcome.Stderr.Length > 0) body.Add("stderr:\n" + outcome.Stderr);
var text = body.Count == 0 ? "(no output)" : string.Join('\n', body);
if (text.Length > MaxNoticePerSkill) text = text[..MaxNoticePerSkill].TrimEnd() + "\n[…truncated]";
return sb.Append('\n').Append(text).ToString();
}
// ---- shared ----
private IReadOnlyList<SkillHookSet> Read()
{
try { return SkillHookFiles.ReadAll(_cwd); }
catch (Exception ex)
{
_logger.LogWarning(ex, "Reading skill hooks in {Cwd} failed", _cwd);
return [];
}
}
/// <summary>Runs one hook; any failure is an outcome. Cancellation (session end, a new generation) gives a "cancelled" outcome.</summary>
private async Task<HookOutcome> RunAsync(SkillHookSet set, SkillHookCommand command, string hook, CancellationToken cancellationToken)
{
await Task.Yield(); // never run the start synchronously on the caller (the loop)
try
{
ProcessSpawnContext spawn;
try
{
spawn = await _prepare(new ProcessSpawnRequest
{
Purpose = ProcessPurpose.SkillHook,
Command = command.Command,
Cwd = set.Directory,
Origin = $"{set.Skill}:{hook}",
Environment = new Dictionary<string, string?>(StringComparer.Ordinal)
{
[SkillHookFiles.WorkspaceVariable] = Path.GetFullPath(_cwd),
[SkillHookFiles.SkillDirVariable] = Path.GetFullPath(set.Directory),
},
}, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return new HookOutcome("", "", null, false, $"process environment hook failed: {ex.Message}");
}
var outcome = await HookProcess.RunAsync(spawn, command.Timeout, cancellationToken).ConfigureAwait(false);
if (!outcome.Succeeded)
_logger.LogInformation("Skill {Skill} {Hook} hook `{Command}`: {Status}", set.Skill, hook, command.Command, outcome.Status(command.Timeout));
return outcome with { Stdout = _redactor.Redact(outcome.Stdout), Stderr = _redactor.Redact(outcome.Stderr) };
}
catch (OperationCanceledException)
{
return new HookOutcome("", "", null, false, "cancelled");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Skill {Skill} {Hook} hook failed", set.Skill, hook);
return new HookOutcome("", "", null, false, ex.Message);
}
}
/// <summary>Waits until every task is done or its deadline passed, whichever is first; the run's abort stops the wait.</summary>
private async Task WaitUntilDeadlinesAsync(IReadOnlyList<(Task Task, DateTimeOffset Deadline)> waits, CancellationToken abort)
{
foreach (var (task, deadline) in waits.OrderBy(w => w.Deadline))
{
if (task.IsCompleted) continue;
var remaining = deadline - _time.GetUtcNow();
if (remaining <= TimeSpan.Zero) continue;
try { await task.WaitAsync(remaining, _time, abort).ConfigureAwait(false); }
catch (TimeoutException) { }
catch (OperationCanceledException) when (abort.IsCancellationRequested) { return; }
}
}
private static string Key(SkillHookSet set, SkillHookCommand? command) => set.Directory + "\n" + command?.Command;
private static string Attribute(string value) =>
value.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");
private sealed record StartedHook(string Key, SkillHookSet Set, SkillHookCommand Command, DateTimeOffset StartedAt, DateTimeOffset Deadline, Task<HookOutcome> Task);
private sealed record TurnEndRun(Task<TurnEndResult[]> Task, DateTimeOffset Deadline);
private sealed record TurnEndResult(SkillHookSet Set, SkillHookCommand Command, HookOutcome Outcome);
}
internal static class DefaultHookPrompts
{
public const string SessionContextPreamble = """
Output of the skills' session-start hooks, run when this session started or was last compacted (the time is in
`at`). It doesn't update by itself: when you need current data, run the skill's command again.
""";
}