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.
This commit is contained in:
EugeneTes
2026-10-16 12:27:44 +00:00
parent d51438e665
commit 644f72068c
20 changed files with 1282 additions and 15 deletions

View File

@@ -84,6 +84,13 @@ Read `docs/architecture.md` first. Key points:
section on every run; the model reads a SKILL.md with `read`. A skill dir's optional `INSTRUCTIONS.md` (even without section on every run; the model reads a SKILL.md with `read`. A skill dir's optional `INSTRUCTIONS.md` (even without
SKILL.md) goes in full into `<skill_instructions>` on every run (16 KB/file, 64 KB total, truncation noted). SKILL.md) goes in full into `<skill_instructions>` on every run (16 KB/file, 64 KB total, truncation noted).
Only the cwd, so chats never see each other's skills. Only the cwd, so chats never see each other's skills.
**Skill hooks** (`Features/SkillHooks/SkillHookRunner`, fields parsed by `SkillHookFiles`): a SKILL.md frontmatter may
declare `session-start` (output into `<skill_session_context>`, run in the background at session start, `/new`,
resume and after compaction), `turn-end` (run in the background after each run, never delaying it; output or a
failure goes to the next run as a hidden `skill_hook` custom message after the prompt, repeats suppressed until a clean
run) and `protected-paths` (`write`/`edit` refused inside them, checked after `tool_call` handlers). Runs from the
skill dir with `VOLCANIC_WORKSPACE`/`VOLCANIC_SKILL_DIR`, the session's `TZ`, `ProcessPurpose.SkillHook`; waits are
bounded by each hook's timeout; failures never break a run. `AgenticCoreOptions.SkillHooks` turns it off.
The Claude Code provider keeps the CLI's own skills off (`--setting-sources ""`, `--disable-slash-commands`). The Claude Code provider keeps the CLI's own skills off (`--setting-sources ""`, `--disable-slash-commands`).
- **Extension discovery**: host code, then `<cwd>/.volcanic/settings.json` / `.volcanic/extensions/*.dll`, then the same - **Extension discovery**: host code, then `<cwd>/.volcanic/settings.json` / `.volcanic/extensions/*.dll`, then the same
under `~/.volcanic/agent`. Earlier sources win name collisions. Each assembly loads in its own under `~/.volcanic/agent`. Earlier sources win name collisions. Each assembly loads in its own

View File

@@ -56,6 +56,7 @@ owns everything for that feature and exposes one entry type:
| `SystemPrompt` | `SystemPromptBuilder` | Base prompt, tools section, guidelines, project context files, environment | | `SystemPrompt` | `SystemPromptBuilder` | Base prompt, tools section, guidelines, project context files, environment |
| `Compaction` | `Compactor` | When to compact, what to summarise, the summary prompt | | `Compaction` | `Compactor` | When to compact, what to summarise, the summary prompt |
| `Agent` | `AgentSession`, `AgentSessionFactory` | The conversation facade and the loop | | `Agent` | `AgentSession`, `AgentSessionFactory` | The conversation facade and the loop |
| `SkillHooks` | `SkillHookRunner` | The hooks skills declare in SKILL.md (`session-start`, `turn-end`) and their `protected-paths` guard on `write`/`edit`; owned by `AgentSession` |
| `Processes` | `ProcessSpawnPolicy` | The environment, command and cwd of every agent-requested process (deny-list, allow-list, secret values, host hooks), host hardening, secret redaction | | `Processes` | `ProcessSpawnPolicy` | The environment, command and cwd of every agent-requested process (deny-list, allow-list, secret values, host hooks), host hardening, secret redaction |
Slices talk to each other only through their entry types and the Abstractions contracts. `AgentSession` Slices talk to each other only through their entry types and the Abstractions contracts. `AgentSession`
@@ -70,7 +71,8 @@ is the composition root of one conversation; a host never touches the other slic
3. activate every extension (host-wide list, per-session extras, discovered assemblies), each getting its 3. activate every extension (host-wide list, per-session extras, discovered assemblies), each getting its
own `IExtensionApi`; registrations are committed only when activation succeeds; own `IExtensionApi`; registrations are committed only when activation succeeds;
4. bind the runtime so extension actions work, restore model/thinking level from the branch on resume; 4. bind the runtime so extension actions work, restore model/thinking level from the branch on resume;
5. emit `session_start`. 5. emit `session_start`, then start the skills' `session-start` hooks in the background (also after `/new`, a
resume and every compaction).
Extensions are instantiated per session, so extension fields are per conversation. Provider registrations Extensions are instantiated per session, so extension fields are per conversation. Provider registrations
also live per session (a `ModelRegistry` per session), which keeps sessions independent. also live per session (a `ModelRegistry` per session), which keeps sessions independent.
@@ -97,6 +99,8 @@ One run (`RunLoopAsync`):
``` ```
threshold compaction check threshold compaction check
skill hooks → wait for session-start hooks and the previous turn-end (each until its deadline at most):
<skill_session_context> for the prompt, a turn-end notice after the prompt
before_agent_start → system prompt options, injected custom messages before_agent_start → system prompt options, injected custom messages
agent_start agent_start
loop: loop:
@@ -105,13 +109,13 @@ loop:
compaction between turns if requested/needed compaction between turns if requested/needed
context → extensions may rewrite the message list context → extensions may rewrite the message list
stream the provider → message_start / message_update* / message_end (with retry + overflow recovery) stream the provider → message_start / message_update* / message_end (with retry + overflow recovery)
tool calls? → tool_call hook, execute (parallel by default), tool_result hook, tool result messages tool calls? → tool_call hook, protected-paths check (write/edit), execute (parallel by default), tool_result hook, tool result messages
(every result, progress update and error is redacted before tool_result handlers, (every result, progress update and error is redacted before tool_result handlers,
and again after them, before tool_execution_end and the persisted message) and again after them, before tool_execution_end and the persisted message)
turn_end turn_end
more tool calls or steering messages or continue requested? → next turn more tool calls or steering messages or continue requested? → next turn
follow-up queued? → next turn follow-up queued? → next turn
agent_end agent_end → the skills' turn-end hooks start in the background (never awaited here)
agent_settled agent_settled
``` ```
@@ -139,6 +143,16 @@ Two independent event systems exist:
(`Block`, `Cancel`, `MarkHandled`) or setting properties (`Text`, `Payload`, `Message`, `Override`). (`Block`, `Cancel`, `MarkHandled`) or setting properties (`Text`, `Payload`, `Message`, `Override`).
Handlers run sequentially in load order and see each other's changes; some events short-circuit. Handlers run sequentially in load order and see each other's changes; some events short-circuit.
## Skill hooks
A skill's SKILL.md may declare `session-start` and `turn-end` commands and `protected-paths`
([telegram-bot.md](telegram-bot.md#skill-hooks-and-protected-paths) has the format). `SkillFiles`'s companion
`SkillHookFiles` (Abstractions) parses them; `SkillHookRunner` (one per `AgentSession`, off with
`AgenticCoreOptions.SkillHooks = false`) runs them through `PrepareProcessAsync` with `ProcessPurpose.SkillHook`, from
the skill directory, with `VOLCANIC_WORKSPACE`/`VOLCANIC_SKILL_DIR` and the session's `TZ`, and redacts their output.
Nothing a hook does can fail a run: errors and timeouts become text for the model or a log line. Waits are bounded
by each hook's deadline (start + timeout), so a run never waits longer than a hook's timeout.
## Processes and secrets ## Processes and secrets
Every process started on the agent's behalf (`bash`, `IExtensionApi.ExecAsync`, MCP server processes, which are Every process started on the agent's behalf (`bash`, `IExtensionApi.ExecAsync`, MCP server processes, which are

View File

@@ -214,7 +214,8 @@ once the total is used up, later files appear with only that note. Empty and unr
Every part of a skill directory is optional: a directory is a skill when it holds a `SKILL.md`, an `INSTRUCTIONS.md` Every part of a skill directory is optional: a directory is a skill when it holds a `SKILL.md`, an `INSTRUCTIONS.md`
or a `jobs/` directory ([skill jobs](#skill-jobs)), in any combination. `/skills` lists every such directory in or a `jobs/` directory ([skill jobs](#skill-jobs)), in any combination. `/skills` lists every such directory in
directory order: a skill with a SKILL.md as `• name – description (+ INSTRUCTIONS.md, 2 jobs)`, any other as directory order: a skill with a SKILL.md as `• name – description (+ INSTRUCTIONS.md, 2 jobs)`, any other as
`• dir – INSTRUCTIONS.md, 2 jobs`, and a broken job file under its skill as `⚠️ <file>: <problem>`. `• dir – INSTRUCTIONS.md, 2 jobs`, and a broken job file under its skill as `⚠️ <file>: <problem>`. Declared
[hooks and protected paths](#skill-hooks-and-protected-paths) are listed the same way.
```markdown ```markdown
--- ---
@@ -225,6 +226,89 @@ description: Plans trips and books hotels. Use when the user asks about travel.
... ...
``` ```
#### Skill hooks and protected paths
A skill's SKILL.md frontmatter may declare three more things, all optional. A skill without them works as before.
```markdown
---
name: gtd
description: GTD: buckets, projects, reviews. Use for tasks and contacts.
session-start: bin/gtd brief # session start hook
session-start-timeout: 20 # seconds, 1–120, default 30
turn-end: bin/gtd check --quiet # end-of-turn hook
turn-end-timeout: 20 # seconds, 1–120, default 30
protected-paths: Входящие/, Сегодня/, Проекты/
protected-hint: "use `gtd move` or `gtd done` (see SKILL.md)"
---
```
The frontmatter is the simplified YAML of every SKILL.md: one `key: value` per line, and `#` after a space starts a
comment in these fields (quote a command that needs a `#`). `protected-paths` is a comma-separated list or a flow
list `[a, b]`, each entry optionally quoted. The file is re-read on every run, so an edit counts from the next
message. `/skills` shows what a skill declares, e.g. `(+ hooks: session-start, turn-end; 3 protected paths)`, and a
field it can't use as `⚠️ .volcanic/skills/gtd/SKILL.md: <problem>` (bad timeout, empty command, a path outside the
workspace); the skill's other fields still work.
**How a hook runs.** Like a skill watcher: `/bin/sh -c "<command>"` from the **skill directory** (so `bin/gtd` is
relative to the skill), stdin closed, with the same filtered environment as `bash`, plus `VOLCANIC_WORKSPACE` (the
chat's workspace), `VOLCANIC_SKILL_DIR` (the skill directory) and `TZ` = the chat's time zone. Known secrets are
redacted from the output. At the timeout the whole process tree is killed. A hook that fails, times out or can't
start never breaks the conversation: the failure becomes text for the agent (below) and a line in the bot's log.
**`session-start`** runs when a session starts: after a bot restart (on the chat's first message), `/new`, a resume,
and again after every compaction (the context is rebuilt then). It runs in the background; the first run of the
session waits for it at most its timeout. A skill (or command) added later is run on the next message. Its stdout
goes into the system prompt, after `<skill_instructions>`:
```
<skill_session_context>
…
<context skill="gtd" command="bin/gtd brief" at="2026-10-16 10:48 (Europe/Warsaw)">
…stdout…
</context>
</skill_session_context>
```
`at` is when the hook started, in the chat's zone. The output stays as it is until the next session start or
compaction (so the cached prompt prefix doesn't change); the agent is told to run the command itself when it needs
fresh data. Limits: 16 KB per skill, 32 KB for all skills (cut output ends with `[…truncated]`). A non-zero exit or a
timeout adds `[session-start hook failed: exit 2]` (or `timed out after 20 s`) and the last 20 lines of stderr; a hook
still running at its timeout shows `[session-start hook still running after 20 s; …]` until it finishes.
**`turn-end`** runs after each of the agent's turns, that is after every run (an answer to you, a scheduled prompt, a
watcher match), in the background: the answer goes out without waiting for it. Exit 0 with no output means all is
well and nothing happens. Anything else (output, a non-zero exit, a timeout) reaches the agent at the start of its
next turn, right after your message, hidden from the chat:
```
[Skill hook "gtd" turn-end (`bin/gtd check --quiet`, exit 1) after your previous turn:]
…stdout…
stderr:
…last 20 lines, only when the hook failed…
```
If the next turn starts while the hook is still running, it waits for the hook at most until the hook's timeout
(counted from when the hook started), then goes on; a late result comes with the turn after. The same notice is not
repeated: once delivered, an identical one stays quiet until the hook has a clean run. If a hook is still running
when the next turn ends, that turn's hook is skipped. Limit: 8 KB per skill.
**`protected-paths`** are folders or files of the workspace (relative to the workspace, not to the skill; a trailing
`/` is optional) that the skill manages with its own commands. `write` and `edit` refuse to change anything inside
them, and the agent gets an error instead:
```
"Сегодня/Позвонить.md" is inside "Сегодня/", which the skill "gtd" manages: change it through the skill's CLI
(see .volcanic/skills/gtd/SKILL.md), not with write or edit. Reading is allowed. Hint: <protected-hint>
```
`read` still works, and `bash` (so the skill's CLI) is not restricted. Paths are compared after resolving `..`,
symbolic links and Unicode normalisation (NFC), so `notes/../Сегодня/x.md`, an absolute path or a link into the folder
are refused too; the comparison is case-sensitive, as on Linux. The agent's `<skills>` list shows each skill's
`<protected_paths>` so it knows before trying. An absolute path, `..`, the workspace itself and `.volcanic/skills/`
(the skills must stay editable) can't be protected. At most 50 paths per skill; `protected-hint` is at most 500
characters.
### Scheduling ### Scheduling
Ask the agent in plain words. It has a `schedule` tool with two kinds of job: Ask the agent in plain words. It has a `schedule` tool with two kinds of job:

View File

@@ -25,8 +25,8 @@ public sealed partial class JobScheduler : IAsyncDisposable
public static readonly TimeSpan MaxTimerDelay = TimeSpan.FromHours(1); public static readonly TimeSpan MaxTimerDelay = TimeSpan.FromHours(1);
public static readonly TimeSpan LateThreshold = TimeSpan.FromSeconds(60); public static readonly TimeSpan LateThreshold = TimeSpan.FromSeconds(60);
public static readonly TimeSpan StopGrace = TimeSpan.FromSeconds(10); public static readonly TimeSpan StopGrace = TimeSpan.FromSeconds(10);
public const string WorkspaceVariable = "VOLCANIC_WORKSPACE"; public const string WorkspaceVariable = SkillHookFiles.WorkspaceVariable;
public const string SkillDirVariable = "VOLCANIC_SKILL_DIR"; public const string SkillDirVariable = SkillHookFiles.SkillDirVariable;
private const string IdAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"; private const string IdAlphabet = "abcdefghjkmnpqrstuvwxyz23456789";
private readonly SchedulerOptions _options; private readonly SchedulerOptions _options;

View File

@@ -225,8 +225,8 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
} }
/// <summary> /// <summary>
/// Every skill directory (SKILL.md, INSTRUCTIONS.md or jobs/, in any combination), with what it holds; a broken job /// Every skill directory (SKILL.md, INSTRUCTIONS.md or jobs/, in any combination), with what it holds (hooks and
/// file is listed with its problem (scheduler on). /// protected paths included); a broken hook field or job file is listed with its problem (job files: scheduler on).
/// </summary> /// </summary>
private async Task<string> SkillsAsync(ChatContext chat, CancellationToken cancellationToken) private async Task<string> SkillsAsync(ChatContext chat, CancellationToken cancellationToken)
{ {
@@ -247,11 +247,14 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
var jobsText = jobCount == 1 ? "1 job" : $"{jobCount.ToString(CultureInfo.InvariantCulture)} jobs"; var jobsText = jobCount == 1 ? "1 job" : $"{jobCount.ToString(CultureInfo.InvariantCulture)} jobs";
var hasInstructions = instructions.TryGetValue(dir, out var i); var hasInstructions = instructions.TryGetValue(dir, out var i);
var instructionsText = i is { Truncated: true } ? "INSTRUCTIONS.md, truncated in the prompt" : "INSTRUCTIONS.md"; var instructionsText = i is { Truncated: true } ? "INSTRUCTIONS.md, truncated in the prompt" : "INSTRUCTIONS.md";
var hooks = SkillHookFiles.Read(cwd, dir);
var hooksText = hooks?.Summary() ?? "";
if (skills.TryGetValue(dir, out var skill)) if (skills.TryGetValue(dir, out var skill))
{ {
var extras = new List<string>(); var extras = new List<string>();
if (hasInstructions) extras.Add(instructionsText); if (hasInstructions) extras.Add(instructionsText);
if (jobCount > 0) extras.Add(jobsText); if (jobCount > 0) extras.Add(jobsText);
if (hooksText.Length > 0) extras.Add(hooksText);
lines.Add($"• **{skill.Name}** – {skill.Description}" + (extras.Count > 0 ? $" (+ {string.Join(", ", extras)})" : "")); lines.Add($"• **{skill.Name}** – {skill.Description}" + (extras.Count > 0 ? $" (+ {string.Join(", ", extras)})" : ""));
} }
else else
@@ -259,9 +262,12 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
var parts = new List<string>(); var parts = new List<string>();
if (hasInstructions) parts.Add(instructionsText); if (hasInstructions) parts.Add(instructionsText);
if (jobCount > 0) parts.Add(jobsText); if (jobCount > 0) parts.Add(jobsText);
if (hooksText.Length > 0) parts.Add(hooksText);
if (parts.Count == 0) parts.Add("SKILL.md without a description (not listed to me)"); if (parts.Count == 0) parts.Add("SKILL.md without a description (not listed to me)");
lines.Add($"• **{name}** – {string.Join(", ", parts)}"); lines.Add($"• **{name}** – {string.Join(", ", parts)}");
} }
foreach (var error in hooks?.Errors ?? [])
lines.Add($" ⚠️ {Path.GetRelativePath(cwd, hooks!.SkillFile)}: {error}");
foreach (var broken in jobs.Where(j => j.Status is SkillJobStatus.Invalid or SkillJobStatus.OverLimit && j.Id.StartsWith(name + "/", StringComparison.Ordinal))) foreach (var broken in jobs.Where(j => j.Status is SkillJobStatus.Invalid or SkillJobStatus.OverLimit && j.Id.StartsWith(name + "/", StringComparison.Ordinal)))
lines.Add($" ⚠️ {broken.File}: {broken.Problem}"); lines.Add($" ⚠️ {broken.File}: {broken.Problem}");
} }

View File

@@ -3,13 +3,13 @@ using Volcanic.AgenticCore.Abstractions.Extensions.Events;
namespace Volcanic.AgenticCore.Abstractions.Processes; namespace Volcanic.AgenticCore.Abstractions.Processes;
/// <summary>Why a process is spawned on the agent's behalf.</summary> /// <summary>Why a process is spawned on the agent's behalf.</summary>
public enum ProcessPurpose { BashTool, ScheduledScript, McpServer, ExtensionExec, Other } public enum ProcessPurpose { BashTool, ScheduledScript, McpServer, ExtensionExec, Other, SkillHook }
/// <summary>What a spawner asks for. The policy turns it into the complete child context.</summary> /// <summary>What a spawner asks for. The policy turns it into the complete child context.</summary>
public sealed record ProcessSpawnRequest public sealed record ProcessSpawnRequest
{ {
public required ProcessPurpose Purpose { get; init; } public required ProcessPurpose Purpose { get; init; }
/// <summary>A shell command line (BashTool, ScheduledScript) or an executable (McpServer, ExtensionExec).</summary> /// <summary>A shell command line (BashTool, ScheduledScript, SkillHook) or an executable (McpServer, ExtensionExec).</summary>
public required string Command { get; init; } public required string Command { get; init; }
public IReadOnlyList<string> Arguments { get; init; } = []; public IReadOnlyList<string> Arguments { get; init; } = [];
public required string Cwd { get; init; } public required string Cwd { get; init; }

View File

@@ -0,0 +1,185 @@
using System.Globalization;
using System.Text;
namespace Volcanic.AgenticCore.Abstractions.Skills;
/// <summary>A hook command declared by a skill: a shell command line run from the skill directory, and its time limit.</summary>
public sealed record SkillHookCommand(string Command, TimeSpan Timeout);
/// <summary>
/// What one skill directory declares in its SKILL.md frontmatter besides <c>name</c> and <c>description</c>:
/// <c>session-start</c>, <c>turn-end</c> (each with an optional <c>…-timeout</c>), <c>protected-paths</c> and
/// <c>protected-hint</c>. Every field is optional. <see cref="Errors"/> lists the fields that were declared but can't
/// be used (the rest still works). <see cref="ProtectedPaths"/> are workspace-relative, <c>/</c>-separated, NFC,
/// without a trailing <c>/</c>.
/// </summary>
public sealed record SkillHookSet(
string Skill, string Directory, string SkillFile,
SkillHookCommand? SessionStart, SkillHookCommand? TurnEnd,
IReadOnlyList<string> ProtectedPaths, string? ProtectedHint, IReadOnlyList<string> Errors)
{
/// <summary>True when the skill declares something, even something broken.</summary>
public bool IsEmpty => SessionStart is null && TurnEnd is null && ProtectedPaths.Count == 0 && Errors.Count == 0;
/// <summary>A short summary for listings: <c>hooks: session-start, turn-end; 2 protected paths</c>; empty when there is nothing.</summary>
public string Summary()
{
var hooks = new List<string>();
if (SessionStart is not null) hooks.Add(SkillHookFiles.SessionStartKey);
if (TurnEnd is not null) hooks.Add(SkillHookFiles.TurnEndKey);
var parts = new List<string>();
if (hooks.Count > 0) parts.Add("hooks: " + string.Join(", ", hooks));
if (ProtectedPaths.Count > 0) parts.Add(ProtectedPaths.Count == 1 ? "1 protected path" : $"{ProtectedPaths.Count} protected paths");
return string.Join("; ", parts);
}
}
/// <summary>
/// Reads the hook fields of the workspace's skills (<see cref="SkillFiles"/>), fresh on every call. Only a SKILL.md
/// declares hooks; its frontmatter is the simplified YAML of <see cref="Frontmatter"/>, and <c>#</c> after a space
/// starts a comment in these fields (quote a command that needs one).
/// </summary>
public static class SkillHookFiles
{
public const string SessionStartKey = "session-start";
public const string SessionStartTimeoutKey = "session-start-timeout";
public const string TurnEndKey = "turn-end";
public const string TurnEndTimeoutKey = "turn-end-timeout";
public const string ProtectedPathsKey = "protected-paths";
public const string ProtectedHintKey = "protected-hint";
/// <summary>Variable with the chat's workspace (absolute), set for hooks and skill watchers.</summary>
public const string WorkspaceVariable = "VOLCANIC_WORKSPACE";
/// <summary>Variable with the skill directory (absolute), set for hooks and skill watchers.</summary>
public const string SkillDirVariable = "VOLCANIC_SKILL_DIR";
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
public const int MinTimeoutSeconds = 1;
public const int MaxTimeoutSeconds = 120;
public const int MaxProtectedPaths = 50;
public const int MaxHintLength = 500;
private const int MaxFrontmatterChars = 16 * 1024;
/// <summary>Every skill directory that declares at least one field (even a broken one), in directory order.</summary>
public static IReadOnlyList<SkillHookSet> ReadAll(string cwd)
{
var result = new List<SkillHookSet>();
foreach (var dir in SkillFiles.Directories(cwd))
{
if (Read(cwd, dir) is { IsEmpty: false } set) result.Add(set);
}
return result;
}
/// <summary>The hook fields of one skill directory; null when it has no readable SKILL.md.</summary>
public static SkillHookSet? Read(string cwd, string skillDir)
{
if (SkillFiles.FindFile(skillDir, SkillFiles.SkillFile) is not { } file) return null;
if (SkillFiles.ReadPrefix(file, MaxFrontmatterChars) is not { } text) return null;
var fields = Frontmatter.Parse(text, stripComments: true);
var errors = new List<string>();
var name = fields.GetValueOrDefault("name");
var skill = string.IsNullOrWhiteSpace(name) ? Path.GetFileName(skillDir) : string.Join(' ', name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
var sessionStart = Command(fields, SessionStartKey, SessionStartTimeoutKey, errors);
var turnEnd = Command(fields, TurnEndKey, TurnEndTimeoutKey, errors);
var paths = fields.TryGetValue(ProtectedPathsKey, out var raw) ? ProtectedPaths(cwd, raw, errors) : [];
var hint = fields.GetValueOrDefault(ProtectedHintKey)?.Trim();
if (string.IsNullOrEmpty(hint)) hint = null;
else if (hint.Length > MaxHintLength) hint = hint[..MaxHintLength] + "…";
return new SkillHookSet(skill, skillDir, file, sessionStart, turnEnd, paths, hint, errors);
}
/// <summary>
/// The entries of a <c>protected-paths</c> value: comma separated, or a YAML flow list <c>[a, b]</c>; each may be
/// quoted. No validation.
/// </summary>
public static IReadOnlyList<string> SplitList(string value)
{
var text = value.Trim();
if (text.StartsWith('[') && text.EndsWith(']')) text = text[1..^1];
return text.Split(',')
.Select(p => p.Trim())
.Select(p => p.Length >= 2 && (p[0] == '"' && p[^1] == '"' || p[0] == '\'' && p[^1] == '\'') ? p[1..^1].Trim() : p)
.Where(p => p.Length > 0)
.ToList();
}
/// <summary>
/// A path in the normal form used for protected paths: NFC, <c>/</c> separators, no <c>.</c> segments, no
/// trailing <c>/</c>.
/// </summary>
public static string Normalize(string path)
{
var segments = path.Normalize(NormalizationForm.FormC).Replace('\\', '/')
.Split('/', StringSplitOptions.RemoveEmptyEntries)
.Where(s => s != ".");
return string.Join('/', segments);
}
private static SkillHookCommand? Command(Dictionary<string, string> fields, string key, string timeoutKey, List<string> errors)
{
var hasCommand = fields.TryGetValue(key, out var command);
var timeout = DefaultTimeout;
if (fields.TryGetValue(timeoutKey, out var rawTimeout))
{
if (!int.TryParse(rawTimeout.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)
|| seconds is < MinTimeoutSeconds or > MaxTimeoutSeconds)
{
errors.Add($"{timeoutKey}: \"{rawTimeout}\" is not a whole number of seconds from {MinTimeoutSeconds} to {MaxTimeoutSeconds}");
return null;
}
timeout = TimeSpan.FromSeconds(seconds);
if (!hasCommand)
{
errors.Add($"{timeoutKey} without {key}");
return null;
}
}
if (!hasCommand) return null;
if (string.IsNullOrWhiteSpace(command))
{
errors.Add($"{key}: the command is empty");
return null;
}
return new SkillHookCommand(command.Trim(), timeout);
}
private static List<string> ProtectedPaths(string cwd, string raw, List<string> errors)
{
var result = new List<string>();
var skillsRoot = Normalize(Path.GetRelativePath(cwd, SkillFiles.Root(cwd)));
foreach (var entry in SplitList(raw))
{
if (Path.IsPathRooted(entry) || entry.StartsWith('~'))
{
errors.Add($"{ProtectedPathsKey}: \"{entry}\" must be relative to the workspace");
continue;
}
var path = Normalize(entry);
if (path.Length == 0 || path.Split('/').Contains(".."))
{
errors.Add($"{ProtectedPathsKey}: \"{entry}\" must name a folder or file inside the workspace");
continue;
}
if (IsSameOrInside(path, skillsRoot) || IsSameOrInside(skillsRoot, path))
{
errors.Add($"{ProtectedPathsKey}: \"{entry}\" would protect the skills themselves ({skillsRoot}/)");
continue;
}
if (result.Contains(path, StringComparer.Ordinal)) continue;
if (result.Count >= MaxProtectedPaths)
{
errors.Add($"{ProtectedPathsKey}: more than {MaxProtectedPaths} paths, \"{entry}\" and later ones are ignored");
break;
}
result.Add(path);
}
return result;
}
private static bool IsSameOrInside(string path, string root) =>
path == root || path.StartsWith(root + "/", StringComparison.Ordinal);
}

View File

@@ -6,6 +6,7 @@ using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers; using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Features.Compaction; using Volcanic.AgenticCore.Features.Compaction;
using Volcanic.AgenticCore.Features.Extensions; using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Features.SkillHooks;
namespace Volcanic.AgenticCore.Features.Agent; namespace Volcanic.AgenticCore.Features.Agent;
@@ -67,6 +68,15 @@ public sealed partial class AgentSession
if (_autoCompaction && _compactor.ShouldCompact(Compactor.ContextTokens(_session.BuildContext()), _model)) if (_autoCompaction && _compactor.ShouldCompact(Compactor.ContextTokens(_session.BuildContext()), _model))
await CompactCoreAsync(CompactionReason.Threshold, null, abort).ConfigureAwait(false); await CompactCoreAsync(CompactionReason.Threshold, null, abort).ConfigureAwait(false);
// Skill hooks: session-start output for the prompt, the previous turn-end's notice. Bounded waits, never throws.
SkillHookRunInput? hooks = null;
if (_skillHooks is not null)
{
try { hooks = await _skillHooks.BeforeRunAsync(abort).ConfigureAwait(false); }
catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "Skill hooks failed before the run"); }
}
_skillSessionContext = hooks?.SessionContext ?? "";
// before_agent_start: system prompt options and injected messages. // before_agent_start: system prompt options and injected messages.
var promptOptions = _promptBuilder.DefaultOptions(_options.SystemPrompt ?? _coreOptions.DefaultSystemPrompt); var promptOptions = _promptBuilder.DefaultOptions(_options.SystemPrompt ?? _coreOptions.DefaultSystemPrompt);
var before = await _runner.EmitAsync(new BeforeAgentStartEvent(prompt?.Text ?? "", images, promptOptions, _promptBuilder.Render), abort).ConfigureAwait(false); var before = await _runner.EmitAsync(new BeforeAgentStartEvent(prompt?.Text ?? "", images, promptOptions, _promptBuilder.Render), abort).ConfigureAwait(false);
@@ -77,6 +87,7 @@ public sealed partial class AgentSession
var pending = new List<Message>(); var pending = new List<Message>();
if (prompt is not null) pending.Add(prompt); if (prompt is not null) pending.Add(prompt);
if (hooks?.Notice is { } notice) pending.Add(notice);
lock (_nextTurnMessages) { pending.AddRange(_nextTurnMessages); _nextTurnMessages.Clear(); } lock (_nextTurnMessages) { pending.AddRange(_nextTurnMessages); _nextTurnMessages.Clear(); }
pending.AddRange(before.InjectedMessages); pending.AddRange(before.InjectedMessages);
pending.AddRange(_steering.Dequeue()); pending.AddRange(_steering.Dequeue());
@@ -145,6 +156,7 @@ public sealed partial class AgentSession
Emit(new AgentSessionEvent.AgentEnd(newMessages, WillRetry: false)); Emit(new AgentSessionEvent.AgentEnd(newMessages, WillRetry: false));
await _runner.EmitAsync(new AgentEndEvent(newMessages), CancellationToken.None).ConfigureAwait(false); await _runner.EmitAsync(new AgentEndEvent(newMessages), CancellationToken.None).ConfigureAwait(false);
_skillHooks?.RunEnded(); // background: never delays the end of the run
if (!abort.IsCancellationRequested && _autoCompaction && _compactor.ShouldCompact(Compactor.ContextTokens(_session.BuildContext()), _model)) if (!abort.IsCancellationRequested && _autoCompaction && _compactor.ShouldCompact(Compactor.ContextTokens(_session.BuildContext()), _model))
await CompactCoreAsync(CompactionReason.Threshold, null, CancellationToken.None).ConfigureAwait(false); await CompactCoreAsync(CompactionReason.Threshold, null, CancellationToken.None).ConfigureAwait(false);
@@ -305,6 +317,7 @@ public sealed partial class AgentSession
var result = before.Override ?? await _compactor.SummarizeAsync(preparation, _model, customInstructions, cancellationToken).ConfigureAwait(false); var result = before.Override ?? await _compactor.SummarizeAsync(preparation, _model, customInstructions, cancellationToken).ConfigureAwait(false);
var entry = await _session.AppendCompactionAsync(result.Summary, result.FirstKeptEntryId, result.TokensBefore, result.Details, result.Usage, cancellationToken).ConfigureAwait(false); var entry = await _session.AppendCompactionAsync(result.Summary, result.FirstKeptEntryId, result.TokensBefore, result.Details, result.Usage, cancellationToken).ConfigureAwait(false);
await _runner.EmitAsync(new SessionCompactEvent(entry, reason, fromExtension), CancellationToken.None).ConfigureAwait(false); await _runner.EmitAsync(new SessionCompactEvent(entry, reason, fromExtension), CancellationToken.None).ConfigureAwait(false);
_skillHooks?.SessionStarted(); // the context is rebuilt: refresh the session-start output for the next run
Emit(new AgentSessionEvent.EntryAppended(entry)); Emit(new AgentSessionEvent.EntryAppended(entry));
Emit(new AgentSessionEvent.CompactionEnd(reason, result, Aborted: false, willRetry, null)); Emit(new AgentSessionEvent.CompactionEnd(reason, result, Aborted: false, willRetry, null));
return result; return result;

View File

@@ -56,6 +56,11 @@ public sealed partial class AgentSession
continue; continue;
} }
var args = VolcanicJson.ToElement(hook.Input); var args = VolcanicJson.ToElement(hook.Input);
if (_skillHooks?.CheckToolCall(call.Name, args) is { } refusal)
{
prepared.Add(new PreparedCall(call, tool, args, ErrorResult(refusal)));
continue;
}
if (abort.IsCancellationRequested) if (abort.IsCancellationRequested)
{ {
prepared.Add(new PreparedCall(call, tool, args, ErrorResult("Operation aborted"))); prepared.Add(new PreparedCall(call, tool, args, ErrorResult("Operation aborted")));

View File

@@ -12,6 +12,7 @@ using Volcanic.AgenticCore.Features.Compaction;
using Volcanic.AgenticCore.Features.Extensions; using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Features.Models; using Volcanic.AgenticCore.Features.Models;
using Volcanic.AgenticCore.Features.Sessions; using Volcanic.AgenticCore.Features.Sessions;
using Volcanic.AgenticCore.Features.SkillHooks;
using Volcanic.AgenticCore.Features.SystemPrompt; using Volcanic.AgenticCore.Features.SystemPrompt;
using Volcanic.AgenticCore.Features.Tools; using Volcanic.AgenticCore.Features.Tools;
using Volcanic.AgenticCore.Hosting; using Volcanic.AgenticCore.Hosting;
@@ -51,6 +52,7 @@ public sealed partial class AgentSession : IAsyncDisposable
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly ISecretRedactor _redactor; private readonly ISecretRedactor _redactor;
private readonly IUserInterface _ui; private readonly IUserInterface _ui;
private readonly SkillHookRunner? _skillHooks;
private readonly PendingMessageQueue _steering; private readonly PendingMessageQueue _steering;
private readonly PendingMessageQueue _followUp; private readonly PendingMessageQueue _followUp;
private readonly List<CustomMessage> _nextTurnMessages = []; private readonly List<CustomMessage> _nextTurnMessages = [];
@@ -67,13 +69,15 @@ public sealed partial class AgentSession : IAsyncDisposable
private bool _isCompacting; private bool _isCompacting;
private CompactOptions? _pendingCompaction; private CompactOptions? _pendingCompaction;
private string? _systemPromptForRun; private string? _systemPromptForRun;
private volatile string _skillSessionContext = "";
private volatile TimeZoneInfo? _timeZoneOverride; private volatile TimeZoneInfo? _timeZoneOverride;
private bool _disposed; private bool _disposed;
internal AgentSession( internal AgentSession(
SessionManager session, ISessionStore store, ExtensionRunner runner, ToolRegistry tools, CommandRegistry commands, SessionManager session, ISessionStore store, ExtensionRunner runner, ToolRegistry tools, CommandRegistry commands,
ModelRegistry models, Compactor compactor, SystemPromptBuilder promptBuilder, AgentSessionOptions options, ModelRegistry models, Compactor compactor, SystemPromptBuilder promptBuilder, AgentSessionOptions options,
AgenticCoreOptions coreOptions, Model? model, ThinkingLevel thinkingLevel, ILogger logger, ISecretRedactor redactor) AgenticCoreOptions coreOptions, Model? model, ThinkingLevel thinkingLevel, ILogger logger, ISecretRedactor redactor,
TimeProvider? time = null)
{ {
_session = session; _session = session;
_store = store; _store = store;
@@ -94,6 +98,9 @@ public sealed partial class AgentSession : IAsyncDisposable
_model = model; _model = model;
_thinkingLevel = thinkingLevel; _thinkingLevel = thinkingLevel;
_autoCompaction = options.AutoCompaction ?? coreOptions.Compaction.Enabled; _autoCompaction = options.AutoCompaction ?? coreOptions.Compaction.Enabled;
if (coreOptions.SkillHooks)
_skillHooks = new SkillHookRunner(options.Cwd, (request, ct) => _runner.PrepareProcessAsync(request, ct), redactor,
time ?? TimeProvider.System, () => TimeZone, logger);
_runner.OnError += error => Emit(new AgentSessionEvent.ExtensionError(error.ExtensionName, error.Event, error.Exception.Message)); _runner.OnError += error => Emit(new AgentSessionEvent.ExtensionError(error.ExtensionName, error.Event, error.Exception.Message));
} }
@@ -303,6 +310,12 @@ public sealed partial class AgentSession : IAsyncDisposable
public void SetActiveTools(IEnumerable<string> names) => _tools.SetActive(names); public void SetActiveTools(IEnumerable<string> names) => _tools.SetActive(names);
/// <summary>The <c>&lt;skill_session_context&gt;</c> body for the current run (skill session-start hooks); empty when there is none.</summary>
internal string SkillSessionContext => _skillSessionContext;
/// <summary>The factory emitted the first <c>session_start</c>: start the skills' session-start hooks.</summary>
internal void SkillHooksSessionStarted() => _skillHooks?.SessionStarted();
// ---- Session management ---- // ---- Session management ----
public async Task SetSessionNameAsync(string? name, CancellationToken cancellationToken = default) public async Task SetSessionNameAsync(string? name, CancellationToken cancellationToken = default)
@@ -349,6 +362,7 @@ public sealed partial class AgentSession : IAsyncDisposable
_session = next; _session = next;
RestoreBranchSettings(); RestoreBranchSettings();
await _runner.EmitAsync(new SessionStartEvent(startReason, previousId), cancellationToken).ConfigureAwait(false); await _runner.EmitAsync(new SessionStartEvent(startReason, previousId), cancellationToken).ConfigureAwait(false);
_skillHooks?.SessionStarted();
Emit(new AgentSessionEvent.SessionInfoChanged(_session.Name)); Emit(new AgentSessionEvent.SessionInfoChanged(_session.Name));
return new SwitchResult(false); return new SwitchResult(false);
} }
@@ -389,6 +403,7 @@ public sealed partial class AgentSession : IAsyncDisposable
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
await AbortAsync().ConfigureAwait(false); await AbortAsync().ConfigureAwait(false);
_skillHooks?.Dispose();
try { await _runner.EmitAsync(new SessionShutdownEvent(SessionShutdownReason.Quit)).ConfigureAwait(false); } try { await _runner.EmitAsync(new SessionShutdownEvent(SessionShutdownReason.Quit)).ConfigureAwait(false); }
catch (Exception ex) { _logger.LogError(ex, "session_shutdown failed"); } catch (Exception ex) { _logger.LogError(ex, "session_shutdown failed"); }
_runner.Invalidate(); _runner.Invalidate();

View File

@@ -108,7 +108,8 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
// The prompt is only rendered by a run, after owner is assigned; the fallback covers an extension rendering it // The prompt is only rendered by a run, after owner is assigned; the fallback covers an extension rendering it
// during activation, and follows the session's own order (AgentSession.TimeZone). // during activation, and follows the session's own order (AgentSession.TimeZone).
var promptBuilder = new SystemPromptBuilder(cwd, () => tools.ActiveTools, _time, var promptBuilder = new SystemPromptBuilder(cwd, () => tools.ActiveTools, _time,
() => owner?.TimeZone ?? options.TimeZone ?? _options.DefaultTimeZone ?? TimeZoneInfo.Local); () => owner?.TimeZone ?? options.TimeZone ?? _options.DefaultTimeZone ?? TimeZoneInfo.Local,
() => owner?.SkillSessionContext);
var compactor = new Compactor(_options.Compaction, models); var compactor = new Compactor(_options.Compaction, models);
Model? model = null; Model? model = null;
@@ -120,7 +121,7 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
} }
var thinking = options.ThinkingLevel ?? _options.DefaultThinkingLevel; var thinking = options.ThinkingLevel ?? _options.DefaultThinkingLevel;
var agentSession = new AgentSession(session, store, runner, tools, commands, models, compactor, promptBuilder, options, _options, model, thinking, logger, _redactor); var agentSession = new AgentSession(session, store, runner, tools, commands, models, compactor, promptBuilder, options, _options, model, thinking, logger, _redactor, _time);
owner = agentSession; owner = agentSession;
runner.Bind(agentSession); runner.Bind(agentSession);
if (resumed) agentSession.RestoreBranchSettings(); if (resumed) agentSession.RestoreBranchSettings();
@@ -129,6 +130,7 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
await agentSession.SetThinkingLevelAsync(ThinkingLevels.Clamp(m, agentSession.ThinkingLevel), cancellationToken).ConfigureAwait(false); await agentSession.SetThinkingLevelAsync(ThinkingLevels.Clamp(m, agentSession.ThinkingLevel), cancellationToken).ConfigureAwait(false);
await runner.EmitAsync(new SessionStartEvent(resumed ? SessionStartReason.Resume : SessionStartReason.Startup), cancellationToken).ConfigureAwait(false); await runner.EmitAsync(new SessionStartEvent(resumed ? SessionStartReason.Resume : SessionStartReason.Startup), cancellationToken).ConfigureAwait(false);
agentSession.SkillHooksSessionStarted();
return agentSession; return agentSession;
} }
} }

View File

@@ -0,0 +1,117 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Text;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Features.SkillHooks;
/// <summary>How one hook command ended. <see cref="StartError"/> is set when it never ran (environment hook, shell).</summary>
public sealed record HookOutcome(string Stdout, string Stderr, int? ExitCode, bool TimedOut, string? StartError = null)
{
/// <summary>Exit 0 and nothing on stdout: nothing to report.</summary>
public bool IsClean => StartError is null && !TimedOut && ExitCode == 0 && Stdout.Trim().Length == 0;
/// <summary>Exit 0, in time.</summary>
public bool Succeeded => StartError is null && !TimedOut && ExitCode == 0;
/// <summary><c>exit 2</c>, <c>timed out after 20 s</c> or <c>failed to start: …</c>.</summary>
public string Status(TimeSpan timeout) =>
StartError is not null ? $"failed to start: {StartError}"
: TimedOut ? $"timed out after {(int)timeout.TotalSeconds} s"
: $"exit {ExitCode}";
}
/// <summary>
/// Runs one prepared hook command with the <c>bash</c> tool's shell (<c>/bin/sh -c</c>, <c>cmd.exe /c</c> on Windows),
/// stdin closed. Kills the whole process tree at the timeout or on cancellation and never waits for the output pipes
/// longer than <see cref="PipeGrace"/> afterwards, so a call ends at most that long after the timeout.
/// </summary>
internal static class HookProcess
{
public const int MaxCapturedChars = 64 * 1024;
public const int StderrTailLines = 20;
public static readonly TimeSpan PipeGrace = TimeSpan.FromSeconds(1);
public static async Task<HookOutcome> RunAsync(ProcessSpawnContext spawn, TimeSpan timeout, CancellationToken cancellationToken)
{
var psi = new ProcessStartInfo
{
FileName = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sh",
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
if (OperatingSystem.IsWindows()) psi.Arguments = $"/d /s /c \"{spawn.Command}\"";
else { psi.ArgumentList.Add("-c"); psi.ArgumentList.Add(spawn.Command); }
spawn.ApplyTo(psi);
using var process = new Process { StartInfo = psi };
try
{
process.Start();
}
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException or UnauthorizedAccessException
or ArgumentException or NotSupportedException)
{
return new HookOutcome("", "", null, false, ex.Message);
}
process.StandardInput.Close();
var stdout = new StringBuilder();
var stderr = new StringBuilder();
var pumps = Task.WhenAll(PumpAsync(process.StandardOutput, stdout), PumpAsync(process.StandardError, stderr));
using var timeoutCts = new CancellationTokenSource(timeout);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
var timedOut = false;
try
{
await process.WaitForExitAsync(linked.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
timedOut = !cancellationToken.IsCancellationRequested;
try { process.Kill(entireProcessTree: true); }
catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException) { /* already gone */ }
}
try { await pumps.WaitAsync(PipeGrace, CancellationToken.None).ConfigureAwait(false); }
catch (TimeoutException) { /* a surviving grandchild holds a pipe: keep what was read */ }
cancellationToken.ThrowIfCancellationRequested();
string Text(StringBuilder sb) { lock (sb) return sb.ToString(); }
int? exitCode = timedOut ? null : process.ExitCode;
return new HookOutcome(Text(stdout), Tail(Text(stderr)), exitCode, timedOut);
}
/// <summary>The last <see cref="StderrTailLines"/> lines, trimmed.</summary>
internal static string Tail(string text)
{
var lines = text.Replace("\r\n", "\n", StringComparison.Ordinal).Trim('\n').Split('\n');
return string.Join('\n', lines.TakeLast(StderrTailLines)).Trim();
}
/// <summary>Reads the whole stream (so the child never blocks on a full pipe) and keeps the first <see cref="MaxCapturedChars"/>.</summary>
private static async Task PumpAsync(StreamReader reader, StringBuilder capture)
{
var buffer = new char[4096];
try
{
int read;
while ((read = await reader.ReadAsync(buffer.AsMemory(), CancellationToken.None).ConfigureAwait(false)) > 0)
{
lock (capture)
{
var room = MaxCapturedChars - capture.Length;
if (room > 0) capture.Append(buffer, 0, Math.Min(room, read));
}
}
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
{
// The pipe broke or the process was disposed: keep what was read.
}
}
}

View File

@@ -0,0 +1,90 @@
using System.Text;
using System.Text.Json;
using Volcanic.AgenticCore.Abstractions.Skills;
namespace Volcanic.AgenticCore.Features.SkillHooks;
/// <summary>
/// Blocks <c>write</c> and <c>edit</c> inside the workspace paths skills declare in <c>protected-paths</c>. Both sides
/// are compared in canonical form: absolute, <c>..</c> resolved, symbolic links of the existing part of the path
/// followed, NFC. The comparison is ordinal (case-sensitive), like the file system on Linux.
/// </summary>
internal static class ProtectedPathGuard
{
/// <summary>The file tools whose <c>path</c> argument is checked.</summary>
public static readonly IReadOnlySet<string> GuardedTools = new HashSet<string>(StringComparer.Ordinal) { "write", "edit" };
private const int MaxLinkHops = 40;
/// <summary>The refusal for this call, or null when it may run.</summary>
public static string? Check(string cwd, string toolName, JsonElement args, IReadOnlyList<SkillHookSet> skills)
{
if (!GuardedTools.Contains(toolName) || skills.All(s => s.ProtectedPaths.Count == 0)) return null;
if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty("path", out var value) || value.ValueKind != JsonValueKind.String) return null;
var requested = value.GetString();
if (string.IsNullOrWhiteSpace(requested)) return null;
string target;
try { target = Canonical(Path.GetFullPath(Path.IsPathRooted(requested) ? requested : Path.Combine(cwd, requested))); }
catch (Exception e) when (e is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException) { return null; }
var root = Canonical(Path.GetFullPath(cwd));
foreach (var skill in skills)
{
foreach (var path in skill.ProtectedPaths)
{
var protectedPath = Canonical(Path.GetFullPath(Path.Combine(root, path)));
if (!IsSameOrInside(target, protectedPath)) continue;
var relative = SkillHookFiles.Normalize(Path.GetRelativePath(cwd, Path.GetFullPath(Path.Combine(cwd, requested))));
return Message(cwd, skill, path, relative);
}
}
return null;
}
public static string Message(string cwd, SkillHookSet skill, string protectedPath, string requested)
{
var skillFile = SkillHookFiles.Normalize(Path.GetRelativePath(cwd, skill.SkillFile));
var sb = new StringBuilder()
.Append("\"").Append(requested).Append("\" is inside \"").Append(protectedPath).Append("/\", which the skill \"")
.Append(skill.Skill).Append("\" manages: change it through the skill's CLI (see ").Append(skillFile)
.Append("), not with write or edit. Reading is allowed.");
if (skill.ProtectedHint is { } hint) sb.Append(" Hint: ").Append(hint);
return sb.ToString();
}
private static bool IsSameOrInside(string path, string root)
{
if (string.Equals(path, root, StringComparison.Ordinal)) return true;
var prefix = root.EndsWith(Path.DirectorySeparatorChar) ? root : root + Path.DirectorySeparatorChar;
return path.StartsWith(prefix, StringComparison.Ordinal);
}
/// <summary>
/// <paramref name="fullPath"/> with every symbolic link among its existing components replaced by its target
/// (repeatedly, at most <see cref="MaxLinkHops"/> times), <c>..</c> resolved, NFC. Components that don't exist yet
/// are kept as they are.
/// </summary>
internal static string Canonical(string fullPath)
{
var current = fullPath;
for (var hop = 0; hop < MaxLinkHops; hop++)
{
var root = Path.GetPathRoot(current) ?? "";
var parts = current[root.Length..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
var built = root;
string? rewritten = null;
for (var i = 0; i < parts.Length; i++)
{
built = Path.Combine(built, parts[i]);
// lstat: also a dangling link, whose target write would create.
if (new FileInfo(built).LinkTarget is not { } link) continue;
var target = Path.IsPathRooted(link) ? link : Path.Combine(Path.GetDirectoryName(built) ?? root, link);
rewritten = Path.GetFullPath(Path.Combine([target, .. parts[(i + 1)..]]));
break;
}
if (rewritten is null) return current.TrimEnd(Path.DirectorySeparatorChar).Normalize(NormalizationForm.FormC) is { Length: > 0 } c ? c : root;
current = rewritten;
}
return current.Normalize(NormalizationForm.FormC);
}
}

View File

@@ -0,0 +1,329 @@
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.
""";
}

View File

@@ -39,6 +39,8 @@ public static class DefaultPrompts
Its body is always in your system prompt (<skill_instructions>), re-read on every run, so you never read it Its body is always in your system prompt (<skill_instructions>), re-read on every run, so you never read it
yourself unless it is marked truncated. Keep it short (it is capped at 16 KB per file, 64 KB in total) and put yourself unless it is marked truncated. Keep it short (it is capped at 16 KB per file, 64 KB in total) and put
task-specific detail in SKILL.md instead. task-specific detail in SKILL.md instead.
A skill with <protected_paths> manages those workspace paths through its own commands: read them freely, but
change them only the way its SKILL.md says; write and edit refuse them.
"""; """;
public const string SkillInstructionsPreamble = """ public const string SkillInstructionsPreamble = """

View File

@@ -97,7 +97,11 @@ public static class SkillCatalog
{ {
sb.Append("\n <skill>\n <name>").Append(Escape(skill.Name)).Append("</name>\n <description>") sb.Append("\n <skill>\n <name>").Append(Escape(skill.Name)).Append("</name>\n <description>")
.Append(Escape(skill.Description)).Append("</description>\n <location>").Append(Escape(skill.Path)) .Append(Escape(skill.Description)).Append("</description>\n <location>").Append(Escape(skill.Path))
.Append("</location>\n </skill>"); .Append("</location>");
if (SkillHookFiles.Read(cwd, System.IO.Path.GetDirectoryName(skill.Path)!) is { ProtectedPaths.Count: > 0 } hooks)
sb.Append("\n <protected_paths>").Append(Escape(string.Join(", ", hooks.ProtectedPaths.Select(p => p + "/"))))
.Append("</protected_paths>");
sb.Append("\n </skill>");
} }
return sb.Append("\n</available_skills>").ToString(); return sb.Append("\n</available_skills>").ToString();
} }

View File

@@ -19,13 +19,17 @@ public sealed class SystemPromptBuilder
private readonly Func<IReadOnlyList<ITool>> _activeTools; private readonly Func<IReadOnlyList<ITool>> _activeTools;
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly Func<TimeZoneInfo> _zone; private readonly Func<TimeZoneInfo> _zone;
private readonly Func<string?> _skillSessionContext;
/// <param name="cwd">The session's working directory.</param> /// <param name="cwd">The session's working directory.</param>
/// <param name="activeTools">The session's active tools, read at every render.</param> /// <param name="activeTools">The session's active tools, read at every render.</param>
/// <param name="time">Clock for the date line; <see cref="TimeProvider.System"/> when null.</param> /// <param name="time">Clock for the date line; <see cref="TimeProvider.System"/> when null.</param>
/// <param name="zone">The session's zone, read at every render; <see cref="TimeZoneInfo.Local"/> when null.</param> /// <param name="zone">The session's zone, read at every render; <see cref="TimeZoneInfo.Local"/> when null.</param>
public SystemPromptBuilder(string cwd, Func<IReadOnlyList<ITool>> activeTools, TimeProvider? time = null, Func<TimeZoneInfo>? zone = null) /// <param name="skillSessionContext">The skills' session-start hook output, read at every render; none when null.</param>
public SystemPromptBuilder(string cwd, Func<IReadOnlyList<ITool>> activeTools, TimeProvider? time = null, Func<TimeZoneInfo>? zone = null,
Func<string?>? skillSessionContext = null)
{ {
_skillSessionContext = skillSessionContext ?? (() => null);
_cwd = cwd; _cwd = cwd;
_activeTools = activeTools; _activeTools = activeTools;
_time = time ?? TimeProvider.System; _time = time ?? TimeProvider.System;
@@ -73,6 +77,8 @@ public sealed class SystemPromptBuilder
var instructions = SkillCatalog.RenderInstructions(SkillCatalog.LoadInstructions(_cwd)); var instructions = SkillCatalog.RenderInstructions(SkillCatalog.LoadInstructions(_cwd));
if (instructions.Length > 0) AppendSection(sb, "skill_instructions", instructions); 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)); AppendSection(sb, "environment", EnvironmentSection(_time.GetUtcNow(), _zone(), _cwd));

View File

@@ -23,6 +23,12 @@ public sealed class AgenticCoreOptions
public bool IncludeBuiltInTools { get; set; } = true; public bool IncludeBuiltInTools { get; set; } = true;
/// <summary>
/// Run the hooks skills declare in their SKILL.md (<c>session-start</c>, <c>turn-end</c>) and enforce their
/// <c>protected-paths</c> on <c>write</c>/<c>edit</c>. On by default; skills without these fields are unaffected.
/// </summary>
public bool SkillHooks { get; set; } = true;
/// <summary><c>provider/model-id</c> used when a session does not specify one.</summary> /// <summary><c>provider/model-id</c> used when a session does not specify one.</summary>
public string? DefaultModel { get; set; } public string? DefaultModel { get; set; }

View File

@@ -26,6 +26,21 @@ public sealed class SkillsChatTests
Assert.Equal("Skills (.volcanic/skills/<name>/)\n• trip-planner – Plans trips", host.LastReply()); Assert.Equal("Skills (.volcanic/skills/<name>/)\n• trip-planner – Plans trips", host.LastReply());
} }
[Fact]
public async Task Skills_shows_declared_hooks_protected_paths_and_broken_hook_fields()
{
await using var host = new ChatHost();
var dir = Path.Combine(host.WorkspaceOf(), ".volcanic", "skills", "gtd");
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "SKILL.md"),
"---\nname: gtd\ndescription: Tasks\nsession-start: gtd brief\nturn-end: gtd check --quiet\nturn-end-timeout: 500\nprotected-paths: Входящие/, Сегодня/\n---\n");
await host.SendAsync("/skills");
Assert.Equal("Skills (.volcanic/skills/<name>/)\n• gtd – Tasks (+ hooks: session-start; 2 protected paths)\n" +
" ⚠️ .volcanic/skills/gtd/SKILL.md: turn-end-timeout: \"500\" is not a whole number of seconds from 1 to 120", host.LastReply());
}
[Fact] [Fact]
public async Task The_model_sees_skills_written_during_the_session_on_the_next_run() public async Task The_model_sees_skills_written_during_the_session_on_the_next_run()
{ {

View File

@@ -0,0 +1,367 @@
using System.Diagnostics;
using System.Text;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Skills;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Testing;
using Volcanic.AgenticCore.Tests.Support;
namespace Volcanic.AgenticCore.Tests.Features;
public class SkillHookFilesTests : IDisposable
{
private readonly string _cwd = Path.Combine(Path.GetTempPath(), "volcanic-skill-hooks", Guid.NewGuid().ToString("N"));
public SkillHookFilesTests() => Directory.CreateDirectory(_cwd);
public void Dispose() { try { Directory.Delete(_cwd, true); } catch (IOException) { } }
private SkillHookSet? Read(string frontmatter, string dir = "gtd")
{
var skillDir = Path.Combine(SkillFiles.Root(_cwd), dir);
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), $"---\n{frontmatter}\n---\nBody\n");
return SkillHookFiles.Read(_cwd, skillDir);
}
[Fact]
public void Reads_every_field_with_defaults_and_comments()
{
var set = Read("""
name: gtd
description: GTD
session-start: bin/gtd brief # start
turn-end: "bin/gtd check --quiet"
turn-end-timeout: 20
protected-paths: Входящие/, Сегодня , ./Проекты//
protected-hint: use gtd move
""")!;
Assert.Equal("gtd", set.Skill);
Assert.Equal(new SkillHookCommand("bin/gtd brief", TimeSpan.FromSeconds(30)), set.SessionStart);
Assert.Equal(new SkillHookCommand("bin/gtd check --quiet", TimeSpan.FromSeconds(20)), set.TurnEnd);
Assert.Equal(["Входящие", "Сегодня", "Проекты"], set.ProtectedPaths);
Assert.Equal("use gtd move", set.ProtectedHint);
Assert.Empty(set.Errors);
Assert.Equal("hooks: session-start, turn-end; 3 protected paths", set.Summary());
}
[Fact]
public void A_flow_list_with_quotes_works_and_NFD_becomes_NFC()
{
var nfd = "Мой".Normalize(NormalizationForm.FormD);
var set = Read($"description: x\nprotected-paths: [\"Входящие/\", '{nfd}/']")!;
Assert.Equal(["Входящие", "Мой".Normalize(NormalizationForm.FormC)], set.ProtectedPaths);
}
[Fact]
public void A_skill_without_the_fields_declares_nothing()
{
var set = Read("name: plain\ndescription: nothing else")!;
Assert.True(set.IsEmpty);
Assert.Empty(SkillHookFiles.ReadAll(_cwd));
}
[Theory]
[InlineData("session-start-timeout: 0\nsession-start: x", "session-start-timeout")]
[InlineData("turn-end-timeout: 121\nturn-end: x", "turn-end-timeout")]
[InlineData("turn-end-timeout: soon\nturn-end: x", "turn-end-timeout")]
[InlineData("turn-end-timeout: 10", "turn-end-timeout without turn-end")]
[InlineData("session-start: ", "the command is empty")]
[InlineData("protected-paths: /etc", "must be relative")]
[InlineData("protected-paths: ../other", "inside the workspace")]
[InlineData("protected-paths: .", "inside the workspace")]
[InlineData("protected-paths: .volcanic", "the skills themselves")]
[InlineData("protected-paths: .volcanic/skills/gtd", "the skills themselves")]
public void Broken_fields_are_reported_and_the_rest_still_works(string fields, string error)
{
var set = Read("description: x\nprotected-paths: Сегодня\n" + fields)!;
Assert.Contains(set.Errors, e => e.Contains(error, StringComparison.Ordinal));
Assert.False(set.IsEmpty);
if (!fields.StartsWith("protected-paths", StringComparison.Ordinal)) Assert.Equal(["Сегодня"], set.ProtectedPaths);
}
[Fact]
public void The_name_falls_back_to_the_directory()
{
Assert.Equal("dir-name", Read("turn-end: x", "dir-name")!.Skill);
}
}
public class SkillHooksSessionTests : IAsyncLifetime
{
private static readonly TimeZoneInfo Warsaw = TimeZoneInfo.FindSystemTimeZoneById("Europe/Warsaw");
private TestHost _host = null!;
public Task InitializeAsync()
{
_host = new TestHost();
return Task.CompletedTask;
}
public async Task DisposeAsync() => await _host.DisposeAsync();
private string SkillDir(string name = "gtd") => Path.Combine(SkillFiles.Root(_host.Cwd), name);
private void WriteSkill(string fields, string name = "gtd")
{
Directory.CreateDirectory(SkillDir(name));
File.WriteAllText(Path.Combine(SkillDir(name), "SKILL.md"), $"---\nname: {name}\ndescription: Test skill\n{fields}\n---\nBody\n");
}
/// <summary>Lets the background turn-end hook of the last run finish before the test changes what it reads.</summary>
private static Task Settle() => Task.Delay(TimeSpan.FromSeconds(1));
private Task<AgentSession> Session() => _host.CreateSessionAsync(b => b.TimeZone = Warsaw);
private string SystemPrompt(int request) => _host.Faux.Requests[request].SystemPrompt ?? "";
/// <summary>The turn-end notices the model got after the prompt of request <paramref name="request"/> (custom messages reach it as user messages).</summary>
private List<Message> Notices(int request)
{
static bool IsNotice(Message m) => m is UserMessage && m.Text.StartsWith("[Skill hook", StringComparison.Ordinal);
var messages = _host.Faux.Requests[request].Messages.ToList();
var prompt = messages.FindLastIndex(m => m is UserMessage && !IsNotice(m));
return messages.Skip(prompt + 1).Where(IsNotice).ToList();
}
[Fact]
public async Task Session_start_output_is_in_the_system_prompt_with_the_chat_zone_and_skill_environment()
{
if (OperatingSystem.IsWindows()) return;
WriteSkill("session-start: echo \"dir=$(pwd) skill=$VOLCANIC_SKILL_DIR ws=$VOLCANIC_WORKSPACE tz=$TZ\"");
_host.Faux.Enqueue(FauxTurn.Say("ok"));
await using var session = await Session();
await session.PromptAsync("hi");
var prompt = SystemPrompt(0);
Assert.Contains("<skill_session_context>", prompt);
Assert.Contains("<context skill=\"gtd\" command=\"echo &quot;dir=", prompt);
Assert.Contains("(Europe/Warsaw)\">", prompt);
var dir = Path.GetFullPath(SkillDir());
Assert.Contains($"dir={dir} skill={dir} ws={Path.GetFullPath(_host.Cwd)} tz=Europe/Warsaw", prompt);
Assert.True(prompt.IndexOf("<skill_session_context>", StringComparison.Ordinal) > prompt.IndexOf("<skills>", StringComparison.Ordinal));
}
[Fact]
public async Task Session_start_runs_once_per_session_and_again_after_new()
{
if (OperatingSystem.IsWindows()) return;
WriteSkill("session-start: echo run >> count.txt; wc -l < count.txt");
_host.Faux.Enqueue(FauxTurn.Say("1"), FauxTurn.Say("2"), FauxTurn.Say("3"));
await using var session = await Session();
await session.PromptAsync("a");
await session.PromptAsync("b");
Assert.Contains(">\n1\n</context>", SystemPrompt(1));
await session.NewSessionAsync();
await session.PromptAsync("c");
Assert.Contains(">\n2\n</context>", SystemPrompt(2));
}
[Fact]
public async Task A_failing_session_start_is_reported_and_the_session_answers()
{
if (OperatingSystem.IsWindows()) return;
WriteSkill("session-start: echo partial; echo broken >&2; exit 3");
_host.Faux.Enqueue(FauxTurn.Say("still here"));
await using var session = await Session();
await session.PromptAsync("hi");
var prompt = SystemPrompt(0);
Assert.Contains("partial\n[session-start hook failed: exit 3]\nbroken", prompt);
Assert.Equal("still here", session.Messages.OfType<AssistantMessage>().Last().Text);
}
[Fact]
public async Task A_slow_session_start_holds_the_first_run_only_until_its_timeout()
{
if (OperatingSystem.IsWindows()) return;
WriteSkill("session-start: sleep 20\nsession-start-timeout: 1");
_host.Faux.Enqueue(FauxTurn.Say("ok"));
await using var session = await Session();
var watch = Stopwatch.StartNew();
await session.PromptAsync("hi");
Assert.True(watch.Elapsed < TimeSpan.FromSeconds(5), watch.Elapsed.ToString());
Assert.Matches("session-start hook (still running after 1 s|failed: timed out after 1 s)", SystemPrompt(0));
}
[Fact]
public async Task Turn_end_output_reaches_the_next_run_once_and_again_after_a_clean_run()
{
if (OperatingSystem.IsWindows()) return;
// The hook reports whatever problem.txt says; an empty file means all is well.
WriteSkill("turn-end: cat \"$VOLCANIC_WORKSPACE/problem.txt\"");
var problem = Path.Combine(_host.Cwd, "problem.txt");
File.WriteAllText(problem, "Сегодня/x.md: no date");
_host.Faux.Enqueue(FauxTurn.Say("1"), FauxTurn.Say("2"), FauxTurn.Say("3"), FauxTurn.Say("4"), FauxTurn.Say("5"), FauxTurn.Say("6"));
await using var session = await Session();
await session.PromptAsync("first");
Assert.Empty(Notices(0));
await session.PromptAsync("second");
var notice = Assert.Single(Notices(1));
Assert.Equal("[Skill hook \"gtd\" turn-end (`cat \"$VOLCANIC_WORKSPACE/problem.txt\"`, exit 0) after your previous turn:]\nСегодня/x.md: no date", notice.Text);
var stored = Assert.Single(session.Messages.OfType<CustomMessage>());
Assert.Equal("skill_hook", stored.CustomType);
Assert.False(stored.Display);
await session.PromptAsync("third"); // the same problem again: quiet
Assert.Empty(Notices(2));
await Settle();
File.WriteAllText(problem, "");
await session.PromptAsync("fourth"); // gets the third run's (repeated) check: still quiet
Assert.Empty(Notices(3));
await Settle();
File.WriteAllText(problem, "Сегодня/x.md: no date");
await session.PromptAsync("fifth"); // gets the fourth run's clean check
Assert.Empty(Notices(4));
await session.PromptAsync("sixth"); // the problem came back after a clean check: news again
Assert.Single(Notices(5));
}
[Fact]
public async Task A_clean_turn_end_adds_nothing_and_a_failing_one_shows_its_exit_and_stderr()
{
if (OperatingSystem.IsWindows()) return;
WriteSkill("turn-end: test -f fail || exit 0; echo oops >&2; exit 2");
_host.Faux.Enqueue(FauxTurn.Say("1"), FauxTurn.Say("2"), FauxTurn.Say("3"));
await using var session = await Session();
await session.PromptAsync("a");
await session.PromptAsync("b");
Assert.Empty(Notices(1));
await Settle();
File.WriteAllText(Path.Combine(SkillDir(), "fail"), "");
await session.PromptAsync("c"); // b's check ran before the file existed
await session.PromptAsync("d");
Assert.Contains("exit 2) after your previous turn:]\nstderr:\noops", Assert.Single(Notices(3)).Text);
}
[Fact]
public async Task Turn_end_never_delays_the_run_and_the_next_run_waits_at_most_its_timeout()
{
if (OperatingSystem.IsWindows()) return;
WriteSkill("turn-end: sleep 20\nturn-end-timeout: 2");
_host.Faux.Enqueue(FauxTurn.Say("1"), FauxTurn.Say("2"), FauxTurn.Say("3"));
await using var session = await Session();
var watch = Stopwatch.StartNew();
await session.PromptAsync("a");
Assert.True(watch.Elapsed < TimeSpan.FromSeconds(1.5), $"first run took {watch.Elapsed}");
watch.Restart();
await session.PromptAsync("b");
Assert.True(watch.Elapsed < TimeSpan.FromSeconds(3.5), $"second run took {watch.Elapsed}");
Assert.True(watch.Elapsed >= TimeSpan.FromSeconds(1), $"second run took {watch.Elapsed}");
}
[Fact]
public async Task A_turn_end_timeout_is_reported()
{
if (OperatingSystem.IsWindows()) return;
WriteSkill("turn-end: sleep 20\nturn-end-timeout: 1");
_host.Faux.Enqueue(FauxTurn.Say("1"), FauxTurn.Say("2"));
await using var session = await Session();
await session.PromptAsync("a");
await Task.Delay(TimeSpan.FromSeconds(2.5)); // the hook was killed after 1 s
await session.PromptAsync("b");
Assert.Contains("timed out after 1 s) after your previous turn:]", Assert.Single(Notices(1)).Text);
}
[Fact]
public async Task Write_and_edit_are_refused_inside_protected_paths_and_read_works()
{
WriteSkill("protected-paths: Сегодня/, Входящие\nprotected-hint: use gtd move");
var today = Path.Combine(_host.Cwd, "Сегодня");
Directory.CreateDirectory(today);
File.WriteAllText(Path.Combine(today, "a.md"), "old");
_host.Faux.Enqueue(
FauxTurn.Tool("write", new { path = "Сегодня/new.md", content = "x" }),
FauxTurn.Tool("edit", new { path = "Сегодня/a.md", edits = new[] { new { oldText = "old", newText = "new" } } }),
FauxTurn.Tool("write", new { path = Path.Combine(_host.Cwd, "Входящие", "deep", "b.md"), content = "x" }),
FauxTurn.Tool("write", new { path = "notes/../Сегодня/c.md", content = "x" }),
FauxTurn.Tool("read", new { path = "Сегодня/a.md" }),
FauxTurn.Tool("write", new { path = "Сегодня-архив/ok.md", content = "x" }),
FauxTurn.Say("done"));
await using var session = await Session();
await session.PromptAsync("go");
var results = session.Messages.OfType<ToolResultMessage>().ToList();
Assert.Equal(6, results.Count);
foreach (var refused in results.Take(4))
{
Assert.True(refused.IsError);
Assert.Contains("which the skill \"gtd\" manages: change it through the skill's CLI (see .volcanic/skills/gtd/SKILL.md), not with write or edit. Reading is allowed. Hint: use gtd move", refused.Text);
}
Assert.Contains("\"Сегодня/new.md\" is inside \"Сегодня/\"", results[0].Text);
Assert.Equal("old", File.ReadAllText(Path.Combine(today, "a.md")));
Assert.False(File.Exists(Path.Combine(today, "new.md")));
Assert.False(Directory.Exists(Path.Combine(_host.Cwd, "Входящие")));
Assert.False(results[4].IsError);
Assert.Contains("old", results[4].Text);
Assert.False(results[5].IsError);
Assert.Contains("<protected_paths>Сегодня/, Входящие/</protected_paths>", SystemPrompt(0));
}
[Fact]
public async Task A_symlink_or_NFD_spelling_does_not_get_around_a_protected_path()
{
if (OperatingSystem.IsWindows()) return;
WriteSkill("protected-paths: Мой");
Directory.CreateDirectory(Path.Combine(_host.Cwd, "Мой"));
Directory.CreateSymbolicLink(Path.Combine(_host.Cwd, "link"), Path.Combine(_host.Cwd, "Мой"));
_host.Faux.Enqueue(
FauxTurn.Tool("write", new { path = "link/a.md", content = "x" }),
FauxTurn.Tool("write", new { path = "Мой".Normalize(NormalizationForm.FormD) + "/b.md", content = "x" }),
FauxTurn.Say("done"));
await using var session = await Session();
await session.PromptAsync("go");
Assert.All(session.Messages.OfType<ToolResultMessage>(), r => Assert.True(r.IsError, r.Text));
Assert.Empty(Directory.EnumerateFileSystemEntries(Path.Combine(_host.Cwd, "Мой")));
}
[Fact]
public async Task Skills_without_hook_fields_change_nothing()
{
WriteSkill("");
_host.Faux.Enqueue(FauxTurn.Tool("write", new { path = "Сегодня/a.md", content = "x" }), FauxTurn.Say("1"), FauxTurn.Say("2"));
await using var session = await Session();
await session.PromptAsync("a");
await session.PromptAsync("b");
Assert.DoesNotContain("<skill_session_context>", SystemPrompt(0));
Assert.DoesNotContain("</protected_paths>", SystemPrompt(0));
Assert.Empty(Notices(2));
Assert.False(session.Messages.OfType<ToolResultMessage>().Single().IsError);
}
[Fact]
public async Task Skill_hooks_can_be_turned_off()
{
await using var host = new TestHost(o => o.SkillHooks = false);
var dir = Path.Combine(SkillFiles.Root(host.Cwd), "gtd");
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "SKILL.md"), "---\ndescription: x\nsession-start: echo hello\nprotected-paths: Сегодня\n---\n");
host.Faux.Enqueue(FauxTurn.Tool("write", new { path = "Сегодня/a.md", content = "x" }), FauxTurn.Say("1"));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("a");
Assert.DoesNotContain("<skill_session_context>", host.Faux.Requests[0].SystemPrompt);
Assert.False(session.Messages.OfType<ToolResultMessage>().Single().IsError);
}
}