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.
""";
}