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; /// What a run gets from the skill hooks: the <skill_session_context> body and a turn-end notice, each possibly empty. internal sealed record SkillHookRunInput(string SessionContext, CustomMessage? Notice); /// /// The skill hooks of one session (). Never throws into the loop; every failure ends up in /// the text the model sees, or in the log. /// /// session-start: started in the background when the session starts (: startup, /// /new, 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. /// turn-end: started in the background when a run ends (), 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). /// protected-paths: refuses write/edit inside them. /// /// 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> _prepare; private readonly ISecretRedactor _redactor; private readonly TimeProvider _time; private readonly Func _zone; private readonly ILogger _logger; private readonly CancellationTokenSource _disposed = new(); private readonly Lock _lock = new(); private CancellationTokenSource _generation = new(); private List _sessionStart = []; private TurnEndRun? _turnEnd; private readonly Dictionary _lastNotice = new(StringComparer.Ordinal); private IReadOnlyList _hooks = []; public SkillHookRunner(string cwd, Func> prepare, ISecretRedactor redactor, TimeProvider time, Func zone, ILogger logger) { _cwd = cwd; _prepare = prepare; _redactor = redactor; _time = time; _zone = zone; _logger = logger; } /// The hooks the last run read; uses them. public IReadOnlyList Current { get { lock (_lock) return _hooks; } } /// A session (re)started or was compacted: forget the session-start output and run the hooks again, in the background. 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); } } /// /// 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. /// public async Task BeforeRunAsync(CancellationToken abort) { var hooks = Read(); List 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); } /// A run ended: start the turn-end hooks in the background, unless the previous ones are still running. 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); } } /// The refusal for a write/edit into a protected path, or null. 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 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 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(); 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\n").Append(body).Append("\n"); } return sb.ToString(); } // ---- turn-end ---- private async Task 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 results) { var blocks = new List(); 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(); 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 Read() { try { return SkillHookFiles.ReadAll(_cwd); } catch (Exception ex) { _logger.LogWarning(ex, "Reading skill hooks in {Cwd} failed", _cwd); return []; } } /// Runs one hook; any failure is an outcome. Cancellation (session end, a new generation) gives a "cancelled" outcome. private async Task 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(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); } } /// Waits until every task is done or its deadline passed, whichever is first; the run's abort stops the wait. 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 Task); private sealed record TurnEndRun(Task 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. """; }