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.
330 lines
15 KiB
C#
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><skill_session_context></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("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """);
|
|
|
|
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.
|
|
""";
|
|
}
|