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:
@@ -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
|
||||
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.
|
||||
**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`).
|
||||
- **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
|
||||
|
||||
@@ -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 |
|
||||
| `Compaction` | `Compactor` | When to compact, what to summarise, the summary prompt |
|
||||
| `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 |
|
||||
|
||||
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
|
||||
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;
|
||||
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
|
||||
also live per session (a `ModelRegistry` per session), which keeps sessions independent.
|
||||
@@ -97,6 +99,8 @@ One run (`RunLoopAsync`):
|
||||
|
||||
```
|
||||
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
|
||||
agent_start
|
||||
loop:
|
||||
@@ -105,13 +109,13 @@ loop:
|
||||
compaction between turns if requested/needed
|
||||
context → extensions may rewrite the message list
|
||||
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,
|
||||
and again after them, before tool_execution_end and the persisted message)
|
||||
turn_end
|
||||
more tool calls or steering messages or continue requested? → 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
|
||||
```
|
||||
|
||||
@@ -139,6 +143,16 @@ Two independent event systems exist:
|
||||
(`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.
|
||||
|
||||
## 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
|
||||
|
||||
Every process started on the agent's behalf (`bash`, `IExtensionApi.ExecAsync`, MCP server processes, which are
|
||||
|
||||
@@ -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`
|
||||
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
|
||||
`• 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
|
||||
---
|
||||
@@ -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
|
||||
|
||||
Ask the agent in plain words. It has a `schedule` tool with two kinds of job:
|
||||
|
||||
@@ -25,8 +25,8 @@ public sealed partial class JobScheduler : IAsyncDisposable
|
||||
public static readonly TimeSpan MaxTimerDelay = TimeSpan.FromHours(1);
|
||||
public static readonly TimeSpan LateThreshold = TimeSpan.FromSeconds(60);
|
||||
public static readonly TimeSpan StopGrace = TimeSpan.FromSeconds(10);
|
||||
public const string WorkspaceVariable = "VOLCANIC_WORKSPACE";
|
||||
public const string SkillDirVariable = "VOLCANIC_SKILL_DIR";
|
||||
public const string WorkspaceVariable = SkillHookFiles.WorkspaceVariable;
|
||||
public const string SkillDirVariable = SkillHookFiles.SkillDirVariable;
|
||||
private const string IdAlphabet = "abcdefghjkmnpqrstuvwxyz23456789";
|
||||
|
||||
private readonly SchedulerOptions _options;
|
||||
|
||||
@@ -225,8 +225,8 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every skill directory (SKILL.md, INSTRUCTIONS.md or jobs/, in any combination), with what it holds; a broken job
|
||||
/// file is listed with its problem (scheduler on).
|
||||
/// Every skill directory (SKILL.md, INSTRUCTIONS.md or jobs/, in any combination), with what it holds (hooks and
|
||||
/// protected paths included); a broken hook field or job file is listed with its problem (job files: scheduler on).
|
||||
/// </summary>
|
||||
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 hasInstructions = instructions.TryGetValue(dir, out var i);
|
||||
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))
|
||||
{
|
||||
var extras = new List<string>();
|
||||
if (hasInstructions) extras.Add(instructionsText);
|
||||
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)})" : ""));
|
||||
}
|
||||
else
|
||||
@@ -259,9 +262,12 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
|
||||
var parts = new List<string>();
|
||||
if (hasInstructions) parts.Add(instructionsText);
|
||||
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)");
|
||||
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)))
|
||||
lines.Add($" ⚠️ {broken.File}: {broken.Problem}");
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ using Volcanic.AgenticCore.Abstractions.Extensions.Events;
|
||||
namespace Volcanic.AgenticCore.Abstractions.Processes;
|
||||
|
||||
/// <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>
|
||||
public sealed record ProcessSpawnRequest
|
||||
{
|
||||
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 IReadOnlyList<string> Arguments { get; init; } = [];
|
||||
public required string Cwd { get; init; }
|
||||
|
||||
185
src/Volcanic.AgenticCore.Abstractions/Skills/SkillHookFiles.cs
Normal file
185
src/Volcanic.AgenticCore.Abstractions/Skills/SkillHookFiles.cs
Normal 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);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Features.Compaction;
|
||||
using Volcanic.AgenticCore.Features.Extensions;
|
||||
using Volcanic.AgenticCore.Features.SkillHooks;
|
||||
|
||||
namespace Volcanic.AgenticCore.Features.Agent;
|
||||
|
||||
@@ -67,6 +68,15 @@ public sealed partial class AgentSession
|
||||
if (_autoCompaction && _compactor.ShouldCompact(Compactor.ContextTokens(_session.BuildContext()), _model))
|
||||
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.
|
||||
var promptOptions = _promptBuilder.DefaultOptions(_options.SystemPrompt ?? _coreOptions.DefaultSystemPrompt);
|
||||
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>();
|
||||
if (prompt is not null) pending.Add(prompt);
|
||||
if (hooks?.Notice is { } notice) pending.Add(notice);
|
||||
lock (_nextTurnMessages) { pending.AddRange(_nextTurnMessages); _nextTurnMessages.Clear(); }
|
||||
pending.AddRange(before.InjectedMessages);
|
||||
pending.AddRange(_steering.Dequeue());
|
||||
@@ -145,6 +156,7 @@ public sealed partial class AgentSession
|
||||
|
||||
Emit(new AgentSessionEvent.AgentEnd(newMessages, WillRetry: 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))
|
||||
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 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);
|
||||
_skillHooks?.SessionStarted(); // the context is rebuilt: refresh the session-start output for the next run
|
||||
Emit(new AgentSessionEvent.EntryAppended(entry));
|
||||
Emit(new AgentSessionEvent.CompactionEnd(reason, result, Aborted: false, willRetry, null));
|
||||
return result;
|
||||
|
||||
@@ -56,6 +56,11 @@ public sealed partial class AgentSession
|
||||
continue;
|
||||
}
|
||||
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)
|
||||
{
|
||||
prepared.Add(new PreparedCall(call, tool, args, ErrorResult("Operation aborted")));
|
||||
|
||||
@@ -12,6 +12,7 @@ using Volcanic.AgenticCore.Features.Compaction;
|
||||
using Volcanic.AgenticCore.Features.Extensions;
|
||||
using Volcanic.AgenticCore.Features.Models;
|
||||
using Volcanic.AgenticCore.Features.Sessions;
|
||||
using Volcanic.AgenticCore.Features.SkillHooks;
|
||||
using Volcanic.AgenticCore.Features.SystemPrompt;
|
||||
using Volcanic.AgenticCore.Features.Tools;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
@@ -51,6 +52,7 @@ public sealed partial class AgentSession : IAsyncDisposable
|
||||
private readonly ILogger _logger;
|
||||
private readonly ISecretRedactor _redactor;
|
||||
private readonly IUserInterface _ui;
|
||||
private readonly SkillHookRunner? _skillHooks;
|
||||
private readonly PendingMessageQueue _steering;
|
||||
private readonly PendingMessageQueue _followUp;
|
||||
private readonly List<CustomMessage> _nextTurnMessages = [];
|
||||
@@ -67,13 +69,15 @@ public sealed partial class AgentSession : IAsyncDisposable
|
||||
private bool _isCompacting;
|
||||
private CompactOptions? _pendingCompaction;
|
||||
private string? _systemPromptForRun;
|
||||
private volatile string _skillSessionContext = "";
|
||||
private volatile TimeZoneInfo? _timeZoneOverride;
|
||||
private bool _disposed;
|
||||
|
||||
internal AgentSession(
|
||||
SessionManager session, ISessionStore store, ExtensionRunner runner, ToolRegistry tools, CommandRegistry commands,
|
||||
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;
|
||||
_store = store;
|
||||
@@ -94,6 +98,9 @@ public sealed partial class AgentSession : IAsyncDisposable
|
||||
_model = model;
|
||||
_thinkingLevel = thinkingLevel;
|
||||
_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));
|
||||
}
|
||||
|
||||
@@ -303,6 +310,12 @@ public sealed partial class AgentSession : IAsyncDisposable
|
||||
|
||||
public void SetActiveTools(IEnumerable<string> names) => _tools.SetActive(names);
|
||||
|
||||
/// <summary>The <c><skill_session_context></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 ----
|
||||
|
||||
public async Task SetSessionNameAsync(string? name, CancellationToken cancellationToken = default)
|
||||
@@ -349,6 +362,7 @@ public sealed partial class AgentSession : IAsyncDisposable
|
||||
_session = next;
|
||||
RestoreBranchSettings();
|
||||
await _runner.EmitAsync(new SessionStartEvent(startReason, previousId), cancellationToken).ConfigureAwait(false);
|
||||
_skillHooks?.SessionStarted();
|
||||
Emit(new AgentSessionEvent.SessionInfoChanged(_session.Name));
|
||||
return new SwitchResult(false);
|
||||
}
|
||||
@@ -389,6 +403,7 @@ public sealed partial class AgentSession : IAsyncDisposable
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
await AbortAsync().ConfigureAwait(false);
|
||||
_skillHooks?.Dispose();
|
||||
try { await _runner.EmitAsync(new SessionShutdownEvent(SessionShutdownReason.Quit)).ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "session_shutdown failed"); }
|
||||
_runner.Invalidate();
|
||||
|
||||
@@ -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
|
||||
// during activation, and follows the session's own order (AgentSession.TimeZone).
|
||||
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);
|
||||
|
||||
Model? model = null;
|
||||
@@ -120,7 +121,7 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
|
||||
}
|
||||
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;
|
||||
runner.Bind(agentSession);
|
||||
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 runner.EmitAsync(new SessionStartEvent(resumed ? SessionStartReason.Resume : SessionStartReason.Startup), cancellationToken).ConfigureAwait(false);
|
||||
agentSession.SkillHooksSessionStarted();
|
||||
return agentSession;
|
||||
}
|
||||
}
|
||||
|
||||
117
src/Volcanic.AgenticCore/Features/SkillHooks/HookProcess.cs
Normal file
117
src/Volcanic.AgenticCore/Features/SkillHooks/HookProcess.cs
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
329
src/Volcanic.AgenticCore/Features/SkillHooks/SkillHookRunner.cs
Normal file
329
src/Volcanic.AgenticCore/Features/SkillHooks/SkillHookRunner.cs
Normal 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><skill_session_context></c> body and a turn-end notice, each possibly empty.</summary>
|
||||
internal sealed record SkillHookRunInput(string SessionContext, CustomMessage? Notice);
|
||||
|
||||
/// <summary>
|
||||
/// The skill hooks of one session (<see cref="SkillHookFiles"/>). Never throws into the loop; every failure ends up in
|
||||
/// the text the model sees, or in the log.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>session-start</c>: started in the background when the session starts (<see cref="SessionStarted"/>: startup,
|
||||
/// <c>/new</c>, resume, after compaction) and on a run that finds a hook it hasn't run yet. A run waits for running hooks at
|
||||
/// most until their deadline (start + timeout), then renders what it has; the output is kept until the next start.</item>
|
||||
/// <item><c>turn-end</c>: started in the background when a run ends (<see cref="RunEnded"/>), never delaying it. The next
|
||||
/// run waits for it at most until its deadline; output or a failure becomes a notice in that run, unless it is the
|
||||
/// same notice as last time (repeats stay quiet until a clean run).</item>
|
||||
/// <item><c>protected-paths</c>: <see cref="CheckToolCall"/> refuses <c>write</c>/<c>edit</c> inside them.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal sealed class SkillHookRunner : IDisposable
|
||||
{
|
||||
public const string NoticeType = "skill_hook";
|
||||
public const int MaxSessionContextPerSkill = 16 * 1024;
|
||||
public const int MaxSessionContextTotal = 32 * 1024;
|
||||
public const int MaxNoticePerSkill = 8 * 1024;
|
||||
|
||||
private readonly string _cwd;
|
||||
private readonly Func<ProcessSpawnRequest, CancellationToken, ValueTask<ProcessSpawnContext>> _prepare;
|
||||
private readonly ISecretRedactor _redactor;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Func<TimeZoneInfo> _zone;
|
||||
private readonly ILogger _logger;
|
||||
private readonly CancellationTokenSource _disposed = new();
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
private CancellationTokenSource _generation = new();
|
||||
private List<StartedHook> _sessionStart = [];
|
||||
private TurnEndRun? _turnEnd;
|
||||
private readonly Dictionary<string, string> _lastNotice = new(StringComparer.Ordinal);
|
||||
private IReadOnlyList<SkillHookSet> _hooks = [];
|
||||
|
||||
public SkillHookRunner(string cwd, Func<ProcessSpawnRequest, CancellationToken, ValueTask<ProcessSpawnContext>> prepare,
|
||||
ISecretRedactor redactor, TimeProvider time, Func<TimeZoneInfo> zone, ILogger logger)
|
||||
{
|
||||
_cwd = cwd;
|
||||
_prepare = prepare;
|
||||
_redactor = redactor;
|
||||
_time = time;
|
||||
_zone = zone;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>The hooks the last run read; <see cref="CheckToolCall"/> uses them.</summary>
|
||||
public IReadOnlyList<SkillHookSet> Current { get { lock (_lock) return _hooks; } }
|
||||
|
||||
/// <summary>A session (re)started or was compacted: forget the session-start output and run the hooks again, in the background.</summary>
|
||||
public void SessionStarted()
|
||||
{
|
||||
if (_disposed.IsCancellationRequested) return;
|
||||
var hooks = Read();
|
||||
lock (_lock)
|
||||
{
|
||||
// Cancelled, not disposed: the old hooks may still be reading the token.
|
||||
_generation.Cancel();
|
||||
_generation = CancellationTokenSource.CreateLinkedTokenSource(_disposed.Token);
|
||||
_sessionStart = [];
|
||||
_hooks = hooks;
|
||||
StartMissingSessionHooks(hooks);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Before a run: re-read the declarations, start session-start hooks not run yet, wait for running ones and the
|
||||
/// previous turn-end (each at most until its deadline), and return what the run gets.
|
||||
/// </summary>
|
||||
public async Task<SkillHookRunInput> BeforeRunAsync(CancellationToken abort)
|
||||
{
|
||||
var hooks = Read();
|
||||
List<StartedHook> started;
|
||||
TurnEndRun? turnEnd;
|
||||
lock (_lock)
|
||||
{
|
||||
_hooks = hooks;
|
||||
StartMissingSessionHooks(hooks);
|
||||
_sessionStart.RemoveAll(h => !hooks.Any(s => Key(s, s.SessionStart) == h.Key));
|
||||
started = [.. _sessionStart];
|
||||
turnEnd = _turnEnd;
|
||||
}
|
||||
|
||||
var waits = started.Select(h => ((Task)h.Task, h.Deadline)).ToList();
|
||||
if (turnEnd is not null) waits.Add((turnEnd.Task, turnEnd.Deadline));
|
||||
await WaitUntilDeadlinesAsync(waits, abort).ConfigureAwait(false);
|
||||
|
||||
CustomMessage? notice = null;
|
||||
if (turnEnd is { Task.IsCompleted: true })
|
||||
{
|
||||
lock (_lock) if (_turnEnd == turnEnd) _turnEnd = null;
|
||||
notice = Notice(turnEnd.Task.Result);
|
||||
}
|
||||
return new SkillHookRunInput(RenderSessionContext(started), notice);
|
||||
}
|
||||
|
||||
/// <summary>A run ended: start the turn-end hooks in the background, unless the previous ones are still running.</summary>
|
||||
public void RunEnded()
|
||||
{
|
||||
if (_disposed.IsCancellationRequested) return;
|
||||
var hooks = Read().Where(h => h.TurnEnd is not null).ToList();
|
||||
if (hooks.Count == 0) return;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_turnEnd is { Task.IsCompleted: false })
|
||||
{
|
||||
_logger.LogDebug("Skill turn-end hooks are still running; this run's are skipped");
|
||||
return;
|
||||
}
|
||||
// A finished result no run has picked up yet is replaced: the newer check supersedes it.
|
||||
var deadline = _time.GetUtcNow() + hooks.Max(h => h.TurnEnd!.Timeout);
|
||||
var token = _disposed.Token;
|
||||
var task = Task.WhenAll(hooks.Select(h => RunTurnEndAsync(h, token)));
|
||||
_turnEnd = new TurnEndRun(task, deadline);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The refusal for a <c>write</c>/<c>edit</c> into a protected path, or null.</summary>
|
||||
public string? CheckToolCall(string toolName, JsonElement args)
|
||||
{
|
||||
if (!ProtectedPathGuard.GuardedTools.Contains(toolName)) return null;
|
||||
try { return ProtectedPathGuard.Check(_cwd, toolName, args, Read()); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Checking protected paths failed for {Tool}", toolName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Cancelled, not disposed: running hooks may still be reading the token.
|
||||
_disposed.Cancel();
|
||||
}
|
||||
|
||||
// ---- session-start ----
|
||||
|
||||
private void StartMissingSessionHooks(IReadOnlyList<SkillHookSet> hooks)
|
||||
{
|
||||
foreach (var set in hooks)
|
||||
{
|
||||
if (set.SessionStart is not { } command) continue;
|
||||
var key = Key(set, command);
|
||||
if (_sessionStart.Any(h => h.Key == key)) continue;
|
||||
var startedAt = _time.GetUtcNow();
|
||||
var task = RunAsync(set, command, SkillHookFiles.SessionStartKey, _generation.Token);
|
||||
_sessionStart.Add(new StartedHook(key, set, command, startedAt, startedAt + command.Timeout, task));
|
||||
}
|
||||
}
|
||||
|
||||
private string RenderSessionContext(IReadOnlyList<StartedHook> hooks)
|
||||
{
|
||||
if (hooks.Count == 0) return "";
|
||||
var zone = _zone();
|
||||
var sb = new StringBuilder(DefaultHookPrompts.SessionContextPreamble);
|
||||
var budget = MaxSessionContextTotal;
|
||||
foreach (var hook in hooks)
|
||||
{
|
||||
var local = TimeZoneInfo.ConvertTime(hook.StartedAt, zone);
|
||||
var at = string.Create(CultureInfo.InvariantCulture, $"{local:yyyy-MM-dd HH:mm} ({TimeZoneIds.Name(zone)})");
|
||||
string body;
|
||||
if (!hook.Task.IsCompleted) body = $"[session-start hook still running after {(int)hook.Command.Timeout.TotalSeconds} s; run the command yourself if you need its output]";
|
||||
else
|
||||
{
|
||||
var outcome = hook.Task.Result;
|
||||
var parts = new List<string>();
|
||||
if (outcome.Stdout.Trim().Length > 0) parts.Add(outcome.Stdout.Trim());
|
||||
if (!outcome.Succeeded)
|
||||
{
|
||||
parts.Add($"[session-start hook failed: {outcome.Status(hook.Command.Timeout)}]");
|
||||
if (outcome.Stderr.Length > 0) parts.Add(outcome.Stderr);
|
||||
}
|
||||
body = parts.Count == 0 ? "(no output)" : string.Join('\n', parts);
|
||||
}
|
||||
var limit = Math.Min(MaxSessionContextPerSkill, Math.Max(budget, 0));
|
||||
if (body.Length > limit) body = body[..limit].TrimEnd() + "\n[…truncated]";
|
||||
budget -= body.Length;
|
||||
|
||||
sb.Append("\n\n<context skill=\"").Append(Attribute(hook.Set.Skill)).Append("\" command=\"").Append(Attribute(hook.Command.Command))
|
||||
.Append("\" at=\"").Append(at).Append("\">\n").Append(body).Append("\n</context>");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ---- turn-end ----
|
||||
|
||||
private async Task<TurnEndResult> RunTurnEndAsync(SkillHookSet set, CancellationToken cancellationToken)
|
||||
{
|
||||
var outcome = await RunAsync(set, set.TurnEnd!, SkillHookFiles.TurnEndKey, cancellationToken).ConfigureAwait(false);
|
||||
return new TurnEndResult(set, set.TurnEnd!, outcome);
|
||||
}
|
||||
|
||||
private CustomMessage? Notice(IReadOnlyList<TurnEndResult> results)
|
||||
{
|
||||
var blocks = new List<string>();
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var result in results)
|
||||
{
|
||||
var key = result.Set.Directory;
|
||||
if (result.Outcome.IsClean)
|
||||
{
|
||||
_lastNotice.Remove(key);
|
||||
continue;
|
||||
}
|
||||
var text = NoticeText(result);
|
||||
if (_lastNotice.TryGetValue(key, out var last) && last == text) continue;
|
||||
_lastNotice[key] = text;
|
||||
blocks.Add(text);
|
||||
}
|
||||
}
|
||||
return blocks.Count == 0 ? null : CustomMessage.FromText(NoticeType, string.Join("\n\n", blocks), display: false);
|
||||
}
|
||||
|
||||
private static string NoticeText(TurnEndResult result)
|
||||
{
|
||||
var outcome = result.Outcome;
|
||||
var sb = new StringBuilder()
|
||||
.Append("[Skill hook \"").Append(result.Set.Skill).Append("\" turn-end (`").Append(result.Command.Command).Append("`, ")
|
||||
.Append(outcome.Status(result.Command.Timeout)).Append(") after your previous turn:]");
|
||||
var body = new List<string>();
|
||||
if (outcome.Stdout.Trim().Length > 0) body.Add(outcome.Stdout.Trim());
|
||||
if (!outcome.Succeeded && outcome.Stderr.Length > 0) body.Add("stderr:\n" + outcome.Stderr);
|
||||
var text = body.Count == 0 ? "(no output)" : string.Join('\n', body);
|
||||
if (text.Length > MaxNoticePerSkill) text = text[..MaxNoticePerSkill].TrimEnd() + "\n[…truncated]";
|
||||
return sb.Append('\n').Append(text).ToString();
|
||||
}
|
||||
|
||||
// ---- shared ----
|
||||
|
||||
private IReadOnlyList<SkillHookSet> Read()
|
||||
{
|
||||
try { return SkillHookFiles.ReadAll(_cwd); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Reading skill hooks in {Cwd} failed", _cwd);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Runs one hook; any failure is an outcome. Cancellation (session end, a new generation) gives a "cancelled" outcome.</summary>
|
||||
private async Task<HookOutcome> RunAsync(SkillHookSet set, SkillHookCommand command, string hook, CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Yield(); // never run the start synchronously on the caller (the loop)
|
||||
try
|
||||
{
|
||||
ProcessSpawnContext spawn;
|
||||
try
|
||||
{
|
||||
spawn = await _prepare(new ProcessSpawnRequest
|
||||
{
|
||||
Purpose = ProcessPurpose.SkillHook,
|
||||
Command = command.Command,
|
||||
Cwd = set.Directory,
|
||||
Origin = $"{set.Skill}:{hook}",
|
||||
Environment = new Dictionary<string, string?>(StringComparer.Ordinal)
|
||||
{
|
||||
[SkillHookFiles.WorkspaceVariable] = Path.GetFullPath(_cwd),
|
||||
[SkillHookFiles.SkillDirVariable] = Path.GetFullPath(set.Directory),
|
||||
},
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return new HookOutcome("", "", null, false, $"process environment hook failed: {ex.Message}");
|
||||
}
|
||||
var outcome = await HookProcess.RunAsync(spawn, command.Timeout, cancellationToken).ConfigureAwait(false);
|
||||
if (!outcome.Succeeded)
|
||||
_logger.LogInformation("Skill {Skill} {Hook} hook `{Command}`: {Status}", set.Skill, hook, command.Command, outcome.Status(command.Timeout));
|
||||
return outcome with { Stdout = _redactor.Redact(outcome.Stdout), Stderr = _redactor.Redact(outcome.Stderr) };
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new HookOutcome("", "", null, false, "cancelled");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Skill {Skill} {Hook} hook failed", set.Skill, hook);
|
||||
return new HookOutcome("", "", null, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Waits until every task is done or its deadline passed, whichever is first; the run's abort stops the wait.</summary>
|
||||
private async Task WaitUntilDeadlinesAsync(IReadOnlyList<(Task Task, DateTimeOffset Deadline)> waits, CancellationToken abort)
|
||||
{
|
||||
foreach (var (task, deadline) in waits.OrderBy(w => w.Deadline))
|
||||
{
|
||||
if (task.IsCompleted) continue;
|
||||
var remaining = deadline - _time.GetUtcNow();
|
||||
if (remaining <= TimeSpan.Zero) continue;
|
||||
try { await task.WaitAsync(remaining, _time, abort).ConfigureAwait(false); }
|
||||
catch (TimeoutException) { }
|
||||
catch (OperationCanceledException) when (abort.IsCancellationRequested) { return; }
|
||||
}
|
||||
}
|
||||
|
||||
private static string Key(SkillHookSet set, SkillHookCommand? command) => set.Directory + "\n" + command?.Command;
|
||||
|
||||
private static string Attribute(string value) =>
|
||||
value.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """);
|
||||
|
||||
private sealed record StartedHook(string Key, SkillHookSet Set, SkillHookCommand Command, DateTimeOffset StartedAt, DateTimeOffset Deadline, Task<HookOutcome> Task);
|
||||
|
||||
private sealed record TurnEndRun(Task<TurnEndResult[]> Task, DateTimeOffset Deadline);
|
||||
|
||||
private sealed record TurnEndResult(SkillHookSet Set, SkillHookCommand Command, HookOutcome Outcome);
|
||||
}
|
||||
|
||||
internal static class DefaultHookPrompts
|
||||
{
|
||||
public const string SessionContextPreamble = """
|
||||
Output of the skills' session-start hooks, run when this session started or was last compacted (the time is in
|
||||
`at`). It doesn't update by itself: when you need current data, run the skill's command again.
|
||||
""";
|
||||
}
|
||||
@@ -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
|
||||
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.
|
||||
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 = """
|
||||
|
||||
@@ -97,7 +97,11 @@ public static class SkillCatalog
|
||||
{
|
||||
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("</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();
|
||||
}
|
||||
|
||||
@@ -19,13 +19,17 @@ public sealed class SystemPromptBuilder
|
||||
private readonly Func<IReadOnlyList<ITool>> _activeTools;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Func<TimeZoneInfo> _zone;
|
||||
private readonly Func<string?> _skillSessionContext;
|
||||
|
||||
/// <param name="cwd">The session's working directory.</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="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;
|
||||
_activeTools = activeTools;
|
||||
_time = time ?? TimeProvider.System;
|
||||
@@ -73,6 +77,8 @@ public sealed class SystemPromptBuilder
|
||||
|
||||
var instructions = SkillCatalog.RenderInstructions(SkillCatalog.LoadInstructions(_cwd));
|
||||
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));
|
||||
|
||||
@@ -23,6 +23,12 @@ public sealed class AgenticCoreOptions
|
||||
|
||||
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>
|
||||
public string? DefaultModel { get; set; }
|
||||
|
||||
|
||||
@@ -26,6 +26,21 @@ public sealed class SkillsChatTests
|
||||
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]
|
||||
public async Task The_model_sees_skills_written_during_the_session_on_the_next_run()
|
||||
{
|
||||
|
||||
367
tests/Volcanic.AgenticCore.Tests/Features/SkillHooksTests.cs
Normal file
367
tests/Volcanic.AgenticCore.Tests/Features/SkillHooksTests.cs
Normal 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 "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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user