From 644f72068c1949f5420cea537aebe80f2165abbe Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Fri, 16 Oct 2026 12:27:44 +0000 Subject: [PATCH] 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 , 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. --- CLAUDE.md | 7 + docs/architecture.md | 20 +- docs/telegram-bot.md | 86 +++- .../JobScheduler.cs | 4 +- .../Chats/ChatCommands.cs | 10 +- .../Processes/ProcessSpawn.cs | 4 +- .../Skills/SkillHookFiles.cs | 185 +++++++++ .../Features/Agent/AgentSession.Loop.cs | 13 + .../Features/Agent/AgentSession.Tools.cs | 5 + .../Features/Agent/AgentSession.cs | 17 +- .../Features/Agent/AgentSessionFactory.cs | 6 +- .../Features/SkillHooks/HookProcess.cs | 117 ++++++ .../Features/SkillHooks/ProtectedPathGuard.cs | 90 +++++ .../Features/SkillHooks/SkillHookRunner.cs | 329 ++++++++++++++++ .../Features/SystemPrompt/DefaultPrompts.cs | 2 + .../Features/SystemPrompt/SkillCatalog.cs | 6 +- .../SystemPrompt/SystemPromptBuilder.cs | 8 +- .../Hosting/AgenticCoreOptions.cs | 6 + .../SkillsChatTests.cs | 15 + .../Features/SkillHooksTests.cs | 367 ++++++++++++++++++ 20 files changed, 1282 insertions(+), 15 deletions(-) create mode 100644 src/Volcanic.AgenticCore.Abstractions/Skills/SkillHookFiles.cs create mode 100644 src/Volcanic.AgenticCore/Features/SkillHooks/HookProcess.cs create mode 100644 src/Volcanic.AgenticCore/Features/SkillHooks/ProtectedPathGuard.cs create mode 100644 src/Volcanic.AgenticCore/Features/SkillHooks/SkillHookRunner.cs create mode 100644 tests/Volcanic.AgenticCore.Tests/Features/SkillHooksTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 89d6d65..51a4e7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` 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 ``, 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 `/.volcanic/settings.json` / `.volcanic/extensions/*.dll`, then the same under `~/.volcanic/agent`. Earlier sources win name collisions. Each assembly loads in its own diff --git a/docs/architecture.md b/docs/architecture.md index 27f714b..2d6cbde 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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): + 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 diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index f98fe3b..8bcef25 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -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 `⚠️ : `. +`• dir – INSTRUCTIONS.md, 2 jobs`, and a broken job file under its skill as `⚠️ : `. 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: ` (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 ""` 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 ``: + +``` + +… + +…stdout… + + +``` + +`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: +``` + +`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 `` list shows each skill's +`` 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: diff --git a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobScheduler.cs b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobScheduler.cs index 00d9855..cee860b 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobScheduler.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobScheduler.cs @@ -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; diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs index cecd563..c4f952d 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs @@ -225,8 +225,8 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon } /// - /// 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). /// private async Task 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(); 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(); 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}"); } diff --git a/src/Volcanic.AgenticCore.Abstractions/Processes/ProcessSpawn.cs b/src/Volcanic.AgenticCore.Abstractions/Processes/ProcessSpawn.cs index 6304327..9bc7369 100644 --- a/src/Volcanic.AgenticCore.Abstractions/Processes/ProcessSpawn.cs +++ b/src/Volcanic.AgenticCore.Abstractions/Processes/ProcessSpawn.cs @@ -3,13 +3,13 @@ using Volcanic.AgenticCore.Abstractions.Extensions.Events; namespace Volcanic.AgenticCore.Abstractions.Processes; /// Why a process is spawned on the agent's behalf. -public enum ProcessPurpose { BashTool, ScheduledScript, McpServer, ExtensionExec, Other } +public enum ProcessPurpose { BashTool, ScheduledScript, McpServer, ExtensionExec, Other, SkillHook } /// What a spawner asks for. The policy turns it into the complete child context. public sealed record ProcessSpawnRequest { public required ProcessPurpose Purpose { get; init; } - /// A shell command line (BashTool, ScheduledScript) or an executable (McpServer, ExtensionExec). + /// A shell command line (BashTool, ScheduledScript, SkillHook) or an executable (McpServer, ExtensionExec). public required string Command { get; init; } public IReadOnlyList Arguments { get; init; } = []; public required string Cwd { get; init; } diff --git a/src/Volcanic.AgenticCore.Abstractions/Skills/SkillHookFiles.cs b/src/Volcanic.AgenticCore.Abstractions/Skills/SkillHookFiles.cs new file mode 100644 index 0000000..0c44b58 --- /dev/null +++ b/src/Volcanic.AgenticCore.Abstractions/Skills/SkillHookFiles.cs @@ -0,0 +1,185 @@ +using System.Globalization; +using System.Text; + +namespace Volcanic.AgenticCore.Abstractions.Skills; + +/// A hook command declared by a skill: a shell command line run from the skill directory, and its time limit. +public sealed record SkillHookCommand(string Command, TimeSpan Timeout); + +/// +/// What one skill directory declares in its SKILL.md frontmatter besides name and description: +/// session-start, turn-end (each with an optional …-timeout), protected-paths and +/// protected-hint. Every field is optional. lists the fields that were declared but can't +/// be used (the rest still works). are workspace-relative, /-separated, NFC, +/// without a trailing /. +/// +public sealed record SkillHookSet( + string Skill, string Directory, string SkillFile, + SkillHookCommand? SessionStart, SkillHookCommand? TurnEnd, + IReadOnlyList ProtectedPaths, string? ProtectedHint, IReadOnlyList Errors) +{ + /// True when the skill declares something, even something broken. + public bool IsEmpty => SessionStart is null && TurnEnd is null && ProtectedPaths.Count == 0 && Errors.Count == 0; + + /// A short summary for listings: hooks: session-start, turn-end; 2 protected paths; empty when there is nothing. + public string Summary() + { + var hooks = new List(); + if (SessionStart is not null) hooks.Add(SkillHookFiles.SessionStartKey); + if (TurnEnd is not null) hooks.Add(SkillHookFiles.TurnEndKey); + var parts = new List(); + 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); + } +} + +/// +/// Reads the hook fields of the workspace's skills (), fresh on every call. Only a SKILL.md +/// declares hooks; its frontmatter is the simplified YAML of , and # after a space +/// starts a comment in these fields (quote a command that needs one). +/// +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"; + + /// Variable with the chat's workspace (absolute), set for hooks and skill watchers. + public const string WorkspaceVariable = "VOLCANIC_WORKSPACE"; + /// Variable with the skill directory (absolute), set for hooks and skill watchers. + 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; + + /// Every skill directory that declares at least one field (even a broken one), in directory order. + public static IReadOnlyList ReadAll(string cwd) + { + var result = new List(); + foreach (var dir in SkillFiles.Directories(cwd)) + { + if (Read(cwd, dir) is { IsEmpty: false } set) result.Add(set); + } + return result; + } + + /// The hook fields of one skill directory; null when it has no readable SKILL.md. + 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(); + + 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); + } + + /// + /// The entries of a protected-paths value: comma separated, or a YAML flow list [a, b]; each may be + /// quoted. No validation. + /// + public static IReadOnlyList 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(); + } + + /// + /// A path in the normal form used for protected paths: NFC, / separators, no . segments, no + /// trailing /. + /// + 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 fields, string key, string timeoutKey, List 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 ProtectedPaths(string cwd, string raw, List errors) + { + var result = new List(); + 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); +} diff --git a/src/Volcanic.AgenticCore/Features/Agent/AgentSession.Loop.cs b/src/Volcanic.AgenticCore/Features/Agent/AgentSession.Loop.cs index e3517e7..0c0c7f9 100644 --- a/src/Volcanic.AgenticCore/Features/Agent/AgentSession.Loop.cs +++ b/src/Volcanic.AgenticCore/Features/Agent/AgentSession.Loop.cs @@ -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(); 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; diff --git a/src/Volcanic.AgenticCore/Features/Agent/AgentSession.Tools.cs b/src/Volcanic.AgenticCore/Features/Agent/AgentSession.Tools.cs index f831b2d..5470937 100644 --- a/src/Volcanic.AgenticCore/Features/Agent/AgentSession.Tools.cs +++ b/src/Volcanic.AgenticCore/Features/Agent/AgentSession.Tools.cs @@ -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"))); diff --git a/src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs b/src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs index fc7c48a..1ddd375 100644 --- a/src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs +++ b/src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs @@ -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 _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 names) => _tools.SetActive(names); + /// The <skill_session_context> body for the current run (skill session-start hooks); empty when there is none. + internal string SkillSessionContext => _skillSessionContext; + + /// The factory emitted the first session_start: start the skills' session-start hooks. + 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(); diff --git a/src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs b/src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs index 2e990f4..c56df61 100644 --- a/src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs +++ b/src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs @@ -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; } } diff --git a/src/Volcanic.AgenticCore/Features/SkillHooks/HookProcess.cs b/src/Volcanic.AgenticCore/Features/SkillHooks/HookProcess.cs new file mode 100644 index 0000000..9748734 --- /dev/null +++ b/src/Volcanic.AgenticCore/Features/SkillHooks/HookProcess.cs @@ -0,0 +1,117 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Text; +using Volcanic.AgenticCore.Abstractions.Processes; + +namespace Volcanic.AgenticCore.Features.SkillHooks; + +/// How one hook command ended. is set when it never ran (environment hook, shell). +public sealed record HookOutcome(string Stdout, string Stderr, int? ExitCode, bool TimedOut, string? StartError = null) +{ + /// Exit 0 and nothing on stdout: nothing to report. + public bool IsClean => StartError is null && !TimedOut && ExitCode == 0 && Stdout.Trim().Length == 0; + + /// Exit 0, in time. + public bool Succeeded => StartError is null && !TimedOut && ExitCode == 0; + + /// exit 2, timed out after 20 s or failed to start: …. + public string Status(TimeSpan timeout) => + StartError is not null ? $"failed to start: {StartError}" + : TimedOut ? $"timed out after {(int)timeout.TotalSeconds} s" + : $"exit {ExitCode}"; +} + +/// +/// Runs one prepared hook command with the bash tool's shell (/bin/sh -c, cmd.exe /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 afterwards, so a call ends at most that long after the timeout. +/// +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 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); + } + + /// The last lines, trimmed. + 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(); + } + + /// Reads the whole stream (so the child never blocks on a full pipe) and keeps the first . + 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. + } + } +} diff --git a/src/Volcanic.AgenticCore/Features/SkillHooks/ProtectedPathGuard.cs b/src/Volcanic.AgenticCore/Features/SkillHooks/ProtectedPathGuard.cs new file mode 100644 index 0000000..1db3df0 --- /dev/null +++ b/src/Volcanic.AgenticCore/Features/SkillHooks/ProtectedPathGuard.cs @@ -0,0 +1,90 @@ +using System.Text; +using System.Text.Json; +using Volcanic.AgenticCore.Abstractions.Skills; + +namespace Volcanic.AgenticCore.Features.SkillHooks; + +/// +/// Blocks write and edit inside the workspace paths skills declare in protected-paths. Both sides +/// are compared in canonical form: absolute, .. resolved, symbolic links of the existing part of the path +/// followed, NFC. The comparison is ordinal (case-sensitive), like the file system on Linux. +/// +internal static class ProtectedPathGuard +{ + /// The file tools whose path argument is checked. + public static readonly IReadOnlySet GuardedTools = new HashSet(StringComparer.Ordinal) { "write", "edit" }; + private const int MaxLinkHops = 40; + + /// The refusal for this call, or null when it may run. + public static string? Check(string cwd, string toolName, JsonElement args, IReadOnlyList 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); + } + + /// + /// with every symbolic link among its existing components replaced by its target + /// (repeatedly, at most times), .. resolved, NFC. Components that don't exist yet + /// are kept as they are. + /// + 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); + } +} diff --git a/src/Volcanic.AgenticCore/Features/SkillHooks/SkillHookRunner.cs b/src/Volcanic.AgenticCore/Features/SkillHooks/SkillHookRunner.cs new file mode 100644 index 0000000..53e04e6 --- /dev/null +++ b/src/Volcanic.AgenticCore/Features/SkillHooks/SkillHookRunner.cs @@ -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; + +/// What a run gets from the skill hooks: the <skill_session_context> body and a turn-end notice, each possibly empty. +internal sealed record SkillHookRunInput(string SessionContext, CustomMessage? Notice); + +/// +/// The skill hooks of one session (). Never throws into the loop; every failure ends up in +/// the text the model sees, or in the log. +/// +/// session-start: started in the background when the session starts (: startup, +/// /new, resume, after compaction) and on a run that finds a hook it hasn't run yet. A run waits for running hooks at +/// most until their deadline (start + timeout), then renders what it has; the output is kept until the next start. +/// turn-end: started in the background when a run ends (), never delaying it. The next +/// run waits for it at most until its deadline; output or a failure becomes a notice in that run, unless it is the +/// same notice as last time (repeats stay quiet until a clean run). +/// protected-paths: refuses write/edit inside them. +/// +/// +internal sealed class SkillHookRunner : IDisposable +{ + public const string NoticeType = "skill_hook"; + public const int MaxSessionContextPerSkill = 16 * 1024; + public const int MaxSessionContextTotal = 32 * 1024; + public const int MaxNoticePerSkill = 8 * 1024; + + private readonly string _cwd; + private readonly Func> _prepare; + private readonly ISecretRedactor _redactor; + private readonly TimeProvider _time; + private readonly Func _zone; + private readonly ILogger _logger; + private readonly CancellationTokenSource _disposed = new(); + private readonly Lock _lock = new(); + + private CancellationTokenSource _generation = new(); + private List _sessionStart = []; + private TurnEndRun? _turnEnd; + private readonly Dictionary _lastNotice = new(StringComparer.Ordinal); + private IReadOnlyList _hooks = []; + + public SkillHookRunner(string cwd, Func> prepare, + ISecretRedactor redactor, TimeProvider time, Func zone, ILogger logger) + { + _cwd = cwd; + _prepare = prepare; + _redactor = redactor; + _time = time; + _zone = zone; + _logger = logger; + } + + /// The hooks the last run read; uses them. + public IReadOnlyList Current { get { lock (_lock) return _hooks; } } + + /// A session (re)started or was compacted: forget the session-start output and run the hooks again, in the background. + public void SessionStarted() + { + if (_disposed.IsCancellationRequested) return; + var hooks = Read(); + lock (_lock) + { + // Cancelled, not disposed: the old hooks may still be reading the token. + _generation.Cancel(); + _generation = CancellationTokenSource.CreateLinkedTokenSource(_disposed.Token); + _sessionStart = []; + _hooks = hooks; + StartMissingSessionHooks(hooks); + } + } + + /// + /// Before a run: re-read the declarations, start session-start hooks not run yet, wait for running ones and the + /// previous turn-end (each at most until its deadline), and return what the run gets. + /// + public async Task BeforeRunAsync(CancellationToken abort) + { + var hooks = Read(); + List started; + TurnEndRun? turnEnd; + lock (_lock) + { + _hooks = hooks; + StartMissingSessionHooks(hooks); + _sessionStart.RemoveAll(h => !hooks.Any(s => Key(s, s.SessionStart) == h.Key)); + started = [.. _sessionStart]; + turnEnd = _turnEnd; + } + + var waits = started.Select(h => ((Task)h.Task, h.Deadline)).ToList(); + if (turnEnd is not null) waits.Add((turnEnd.Task, turnEnd.Deadline)); + await WaitUntilDeadlinesAsync(waits, abort).ConfigureAwait(false); + + CustomMessage? notice = null; + if (turnEnd is { Task.IsCompleted: true }) + { + lock (_lock) if (_turnEnd == turnEnd) _turnEnd = null; + notice = Notice(turnEnd.Task.Result); + } + return new SkillHookRunInput(RenderSessionContext(started), notice); + } + + /// A run ended: start the turn-end hooks in the background, unless the previous ones are still running. + public void RunEnded() + { + if (_disposed.IsCancellationRequested) return; + var hooks = Read().Where(h => h.TurnEnd is not null).ToList(); + if (hooks.Count == 0) return; + lock (_lock) + { + if (_turnEnd is { Task.IsCompleted: false }) + { + _logger.LogDebug("Skill turn-end hooks are still running; this run's are skipped"); + return; + } + // A finished result no run has picked up yet is replaced: the newer check supersedes it. + var deadline = _time.GetUtcNow() + hooks.Max(h => h.TurnEnd!.Timeout); + var token = _disposed.Token; + var task = Task.WhenAll(hooks.Select(h => RunTurnEndAsync(h, token))); + _turnEnd = new TurnEndRun(task, deadline); + } + } + + /// The refusal for a write/edit into a protected path, or null. + public string? CheckToolCall(string toolName, JsonElement args) + { + if (!ProtectedPathGuard.GuardedTools.Contains(toolName)) return null; + try { return ProtectedPathGuard.Check(_cwd, toolName, args, Read()); } + catch (Exception ex) + { + _logger.LogWarning(ex, "Checking protected paths failed for {Tool}", toolName); + return null; + } + } + + public void Dispose() + { + // Cancelled, not disposed: running hooks may still be reading the token. + _disposed.Cancel(); + } + + // ---- session-start ---- + + private void StartMissingSessionHooks(IReadOnlyList hooks) + { + foreach (var set in hooks) + { + if (set.SessionStart is not { } command) continue; + var key = Key(set, command); + if (_sessionStart.Any(h => h.Key == key)) continue; + var startedAt = _time.GetUtcNow(); + var task = RunAsync(set, command, SkillHookFiles.SessionStartKey, _generation.Token); + _sessionStart.Add(new StartedHook(key, set, command, startedAt, startedAt + command.Timeout, task)); + } + } + + private string RenderSessionContext(IReadOnlyList hooks) + { + if (hooks.Count == 0) return ""; + var zone = _zone(); + var sb = new StringBuilder(DefaultHookPrompts.SessionContextPreamble); + var budget = MaxSessionContextTotal; + foreach (var hook in hooks) + { + var local = TimeZoneInfo.ConvertTime(hook.StartedAt, zone); + var at = string.Create(CultureInfo.InvariantCulture, $"{local:yyyy-MM-dd HH:mm} ({TimeZoneIds.Name(zone)})"); + string body; + if (!hook.Task.IsCompleted) body = $"[session-start hook still running after {(int)hook.Command.Timeout.TotalSeconds} s; run the command yourself if you need its output]"; + else + { + var outcome = hook.Task.Result; + var parts = new List(); + if (outcome.Stdout.Trim().Length > 0) parts.Add(outcome.Stdout.Trim()); + if (!outcome.Succeeded) + { + parts.Add($"[session-start hook failed: {outcome.Status(hook.Command.Timeout)}]"); + if (outcome.Stderr.Length > 0) parts.Add(outcome.Stderr); + } + body = parts.Count == 0 ? "(no output)" : string.Join('\n', parts); + } + var limit = Math.Min(MaxSessionContextPerSkill, Math.Max(budget, 0)); + if (body.Length > limit) body = body[..limit].TrimEnd() + "\n[…truncated]"; + budget -= body.Length; + + sb.Append("\n\n\n").Append(body).Append("\n"); + } + return sb.ToString(); + } + + // ---- turn-end ---- + + private async Task RunTurnEndAsync(SkillHookSet set, CancellationToken cancellationToken) + { + var outcome = await RunAsync(set, set.TurnEnd!, SkillHookFiles.TurnEndKey, cancellationToken).ConfigureAwait(false); + return new TurnEndResult(set, set.TurnEnd!, outcome); + } + + private CustomMessage? Notice(IReadOnlyList results) + { + var blocks = new List(); + lock (_lock) + { + foreach (var result in results) + { + var key = result.Set.Directory; + if (result.Outcome.IsClean) + { + _lastNotice.Remove(key); + continue; + } + var text = NoticeText(result); + if (_lastNotice.TryGetValue(key, out var last) && last == text) continue; + _lastNotice[key] = text; + blocks.Add(text); + } + } + return blocks.Count == 0 ? null : CustomMessage.FromText(NoticeType, string.Join("\n\n", blocks), display: false); + } + + private static string NoticeText(TurnEndResult result) + { + var outcome = result.Outcome; + var sb = new StringBuilder() + .Append("[Skill hook \"").Append(result.Set.Skill).Append("\" turn-end (`").Append(result.Command.Command).Append("`, ") + .Append(outcome.Status(result.Command.Timeout)).Append(") after your previous turn:]"); + var body = new List(); + if (outcome.Stdout.Trim().Length > 0) body.Add(outcome.Stdout.Trim()); + if (!outcome.Succeeded && outcome.Stderr.Length > 0) body.Add("stderr:\n" + outcome.Stderr); + var text = body.Count == 0 ? "(no output)" : string.Join('\n', body); + if (text.Length > MaxNoticePerSkill) text = text[..MaxNoticePerSkill].TrimEnd() + "\n[…truncated]"; + return sb.Append('\n').Append(text).ToString(); + } + + // ---- shared ---- + + private IReadOnlyList Read() + { + try { return SkillHookFiles.ReadAll(_cwd); } + catch (Exception ex) + { + _logger.LogWarning(ex, "Reading skill hooks in {Cwd} failed", _cwd); + return []; + } + } + + /// Runs one hook; any failure is an outcome. Cancellation (session end, a new generation) gives a "cancelled" outcome. + private async Task RunAsync(SkillHookSet set, SkillHookCommand command, string hook, CancellationToken cancellationToken) + { + await Task.Yield(); // never run the start synchronously on the caller (the loop) + try + { + ProcessSpawnContext spawn; + try + { + spawn = await _prepare(new ProcessSpawnRequest + { + Purpose = ProcessPurpose.SkillHook, + Command = command.Command, + Cwd = set.Directory, + Origin = $"{set.Skill}:{hook}", + Environment = new Dictionary(StringComparer.Ordinal) + { + [SkillHookFiles.WorkspaceVariable] = Path.GetFullPath(_cwd), + [SkillHookFiles.SkillDirVariable] = Path.GetFullPath(set.Directory), + }, + }, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return new HookOutcome("", "", null, false, $"process environment hook failed: {ex.Message}"); + } + var outcome = await HookProcess.RunAsync(spawn, command.Timeout, cancellationToken).ConfigureAwait(false); + if (!outcome.Succeeded) + _logger.LogInformation("Skill {Skill} {Hook} hook `{Command}`: {Status}", set.Skill, hook, command.Command, outcome.Status(command.Timeout)); + return outcome with { Stdout = _redactor.Redact(outcome.Stdout), Stderr = _redactor.Redact(outcome.Stderr) }; + } + catch (OperationCanceledException) + { + return new HookOutcome("", "", null, false, "cancelled"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Skill {Skill} {Hook} hook failed", set.Skill, hook); + return new HookOutcome("", "", null, false, ex.Message); + } + } + + /// Waits until every task is done or its deadline passed, whichever is first; the run's abort stops the wait. + private async Task WaitUntilDeadlinesAsync(IReadOnlyList<(Task Task, DateTimeOffset Deadline)> waits, CancellationToken abort) + { + foreach (var (task, deadline) in waits.OrderBy(w => w.Deadline)) + { + if (task.IsCompleted) continue; + var remaining = deadline - _time.GetUtcNow(); + if (remaining <= TimeSpan.Zero) continue; + try { await task.WaitAsync(remaining, _time, abort).ConfigureAwait(false); } + catch (TimeoutException) { } + catch (OperationCanceledException) when (abort.IsCancellationRequested) { return; } + } + } + + private static string Key(SkillHookSet set, SkillHookCommand? command) => set.Directory + "\n" + command?.Command; + + private static string Attribute(string value) => + value.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """); + + private sealed record StartedHook(string Key, SkillHookSet Set, SkillHookCommand Command, DateTimeOffset StartedAt, DateTimeOffset Deadline, Task Task); + + private sealed record TurnEndRun(Task Task, DateTimeOffset Deadline); + + private sealed record TurnEndResult(SkillHookSet Set, SkillHookCommand Command, HookOutcome Outcome); +} + +internal static class DefaultHookPrompts +{ + public const string SessionContextPreamble = """ + Output of the skills' session-start hooks, run when this session started or was last compacted (the time is in + `at`). It doesn't update by itself: when you need current data, run the skill's command again. + """; +} diff --git a/src/Volcanic.AgenticCore/Features/SystemPrompt/DefaultPrompts.cs b/src/Volcanic.AgenticCore/Features/SystemPrompt/DefaultPrompts.cs index 35c8099..2726cb8 100644 --- a/src/Volcanic.AgenticCore/Features/SystemPrompt/DefaultPrompts.cs +++ b/src/Volcanic.AgenticCore/Features/SystemPrompt/DefaultPrompts.cs @@ -39,6 +39,8 @@ public static class DefaultPrompts Its body is always in your system prompt (), 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 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 = """ diff --git a/src/Volcanic.AgenticCore/Features/SystemPrompt/SkillCatalog.cs b/src/Volcanic.AgenticCore/Features/SystemPrompt/SkillCatalog.cs index 3535b9b..f675a81 100644 --- a/src/Volcanic.AgenticCore/Features/SystemPrompt/SkillCatalog.cs +++ b/src/Volcanic.AgenticCore/Features/SystemPrompt/SkillCatalog.cs @@ -97,7 +97,11 @@ public static class SkillCatalog { sb.Append("\n \n ").Append(Escape(skill.Name)).Append("\n ") .Append(Escape(skill.Description)).Append("\n ").Append(Escape(skill.Path)) - .Append("\n "); + .Append(""); + if (SkillHookFiles.Read(cwd, System.IO.Path.GetDirectoryName(skill.Path)!) is { ProtectedPaths.Count: > 0 } hooks) + sb.Append("\n ").Append(Escape(string.Join(", ", hooks.ProtectedPaths.Select(p => p + "/")))) + .Append(""); + sb.Append("\n "); } return sb.Append("\n").ToString(); } diff --git a/src/Volcanic.AgenticCore/Features/SystemPrompt/SystemPromptBuilder.cs b/src/Volcanic.AgenticCore/Features/SystemPrompt/SystemPromptBuilder.cs index 103efbe..3a51959 100644 --- a/src/Volcanic.AgenticCore/Features/SystemPrompt/SystemPromptBuilder.cs +++ b/src/Volcanic.AgenticCore/Features/SystemPrompt/SystemPromptBuilder.cs @@ -19,13 +19,17 @@ public sealed class SystemPromptBuilder private readonly Func> _activeTools; private readonly TimeProvider _time; private readonly Func _zone; + private readonly Func _skillSessionContext; /// The session's working directory. /// The session's active tools, read at every render. /// Clock for the date line; when null. /// The session's zone, read at every render; when null. - public SystemPromptBuilder(string cwd, Func> activeTools, TimeProvider? time = null, Func? zone = null) + /// The skills' session-start hook output, read at every render; none when null. + public SystemPromptBuilder(string cwd, Func> activeTools, TimeProvider? time = null, Func? zone = null, + Func? 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)); diff --git a/src/Volcanic.AgenticCore/Hosting/AgenticCoreOptions.cs b/src/Volcanic.AgenticCore/Hosting/AgenticCoreOptions.cs index 61fc8ea..92c3740 100644 --- a/src/Volcanic.AgenticCore/Hosting/AgenticCoreOptions.cs +++ b/src/Volcanic.AgenticCore/Hosting/AgenticCoreOptions.cs @@ -23,6 +23,12 @@ public sealed class AgenticCoreOptions public bool IncludeBuiltInTools { get; set; } = true; + /// + /// Run the hooks skills declare in their SKILL.md (session-start, turn-end) and enforce their + /// protected-paths on write/edit. On by default; skills without these fields are unaffected. + /// + public bool SkillHooks { get; set; } = true; + /// provider/model-id used when a session does not specify one. public string? DefaultModel { get; set; } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/SkillsChatTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/SkillsChatTests.cs index 2877aa8..c418318 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/SkillsChatTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/SkillsChatTests.cs @@ -26,6 +26,21 @@ public sealed class SkillsChatTests Assert.Equal("Skills (.volcanic/skills//)\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//)\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() { diff --git a/tests/Volcanic.AgenticCore.Tests/Features/SkillHooksTests.cs b/tests/Volcanic.AgenticCore.Tests/Features/SkillHooksTests.cs new file mode 100644 index 0000000..c928496 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Tests/Features/SkillHooksTests.cs @@ -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"); + } + + /// Lets the background turn-end hook of the last run finish before the test changes what it reads. + private static Task Settle() => Task.Delay(TimeSpan.FromSeconds(1)); + + private Task Session() => _host.CreateSessionAsync(b => b.TimeZone = Warsaw); + + private string SystemPrompt(int request) => _host.Faux.Requests[request].SystemPrompt ?? ""; + + /// The turn-end notices the model got after the prompt of request (custom messages reach it as user messages). + private List 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("", prompt); + Assert.Contains("", 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("", StringComparison.Ordinal) > prompt.IndexOf("", 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", SystemPrompt(1)); + + await session.NewSessionAsync(); + await session.PromptAsync("c"); + Assert.Contains(">\n2\n", 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().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()); + 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().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("Сегодня/, Входящие/", 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(), 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("", SystemPrompt(0)); + Assert.DoesNotContain("", SystemPrompt(0)); + Assert.Empty(Notices(2)); + Assert.False(session.Messages.OfType().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("", host.Faux.Requests[0].SystemPrompt); + Assert.False(session.Messages.OfType().Single().IsError); + } +}