UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

115 lines (114 loc) • 9.12 kB
/** * RB-410 件1 — the Agent Skills (`SKILL.md`) directory loader. * * The open skills format (published at agentskills.io, reference implementation `anthropics/skills`) is * a directory per skill: `skill-name/SKILL.md` holding a `---` frontmatter block plus a Markdown body, * with optional `scripts/` / `references/` / `assets/` resource directories beside it. sema's * {@link SkillSpec} is already the same shape — frontmatter → `name`/`description`, body → `content`, * sibling resources → `files[]` — so the only missing piece was this reader. No engine mechanism * changes: the output is an ordinary `SkillSpec[]` a deployment hands to `TaskSpec.skills`. * * **Deployment-half helper, called BEFORE a task starts.** It reads the filesystem synchronously and is * NOT reachable from the run loop — the engine still never resolves a skill path at tool time, and never * writes. An embedder serving skills from a remote/sandboxed filesystem should read through its own * `ExecutionEnv` instead and construct `SkillSpec` objects directly; this helper is the local-disk case. * * **Trust contract is unchanged and is the caller's:** a skill IS instructions, so everything read here * (body and attachments alike) enters the model's context as CALLER-TRUSTED material. Pointing this at a * directory third parties can write to is the same as pasting their text into the system prompt. The * loader validates SHAPE (spec conformance), never intent. * * **`allowed-tools` / `disallowed-tools` are COPIED, never judged for what they mean.** Each frontmatter list reaches * {@link SkillSpec.allowedTools} / {@link SkillSpec.disallowedTools} entry by entry as the author wrote it — no * intersection with a roster, no projection onto a tool name, no de-duplication. Each entry must be a rule SPELLING — the * rule syntax's own test, `ruleSpellingOf` (a tool name, then at most one parenthesized rule) — or the skill is refused; * what a spelled entry means (a tool, a namespace, a command rule, a path, a wildcard that grants nothing) is read in ONE * place, the run's grant table, which announces what it cannot read. The deny list has a second spelling, * `disallowedTools`, read where `disallowed-tools` is absent or null (YAML's null is the key's absence); a hyphen key with * a value is the list and the alias is not looked at. The allow list has none. **Each list is read whole by ONE scanner, * {@link scanValue} (its JSDoc states the rules), or the skill is refused by name (`no_frontmatter`)** — never installed * shorter: a `disallowed-tools` that lost entries is a looser skill than the one its author wrote. The scanner reads YAML's * structure as YAML does — a comment, a flow's `,` / `]` and a mapping's `: ` are YAML's inside a rule's parentheses too — * and departs from what YAML and the reference's reader give only where the list is cut into entries, on purpose: the cut * counts a rule's parentheses by depth and refuses an entry whose parentheses are not one group closing it — unmatched * either way, or followed by more text (the reference's zone is boolean and installs such an entry); a `*` entry is * copied, the list not collapsed to `*` alone; tab-indented block items are read (the reference's retry reads them too); * and a block scalar's text is cut line by line — each line of a literal one (`|`) as written, a folded one (`>`) after * YAML's folding, so `>` lines that hold one rule read as that rule (YAML keeps the line breaks of `|` in one string, which * the reference installs as one entry no rule reads). * * Frontmatter is read by a deliberately closed subset reader (no YAML dependency — the runtime dependency budget is a * standing constraint): top-level key lines ({@link yamlKeyLineOf}), `#` comment lines, block scalars (`|` / `>` with their * indentation and chomping indicators), the tool lists, and nested blocks (a mapping or a sequence under a key with nothing * on its line) skipped wholesale but for a line that opens a tool-list key (below). A key other than the tool lists keeps its * value as written on its line (a value that is one quoted scalar, the scanner's own judgment, is that scalar decoded, over * every line it runs); a value of it that opens a quote, a `[` or a `{` and does not * close it refuses the skill too — the same scanner, over the same lines, says so. Every value takes its own lines and no * more, and every line no value takes is judged in one place: an indented `key:` line under a value that is not a nested * block — plain, quoted, a flow, a block scalar — refuses the skill naming both keys (YAML reads no key there), and a * flush-left line that is no key line refuses it naming the line (YAML reads no key there either). A tool list is read only * as a key of the frontmatter, written flush left: a tool-list key written in another key's nested block (at any depth), or * after another key's closing quote or `]` on its line, or as a key of a flow mapping (or a pair in a flow sequence) that * value holds, refuses the skill naming both keys — its list would be lost. */ import type { SkillSpec } from "./types.js"; /** Why one skill directory (or one of its parts) was not taken as-declared. */ export type SkillsDirectoryWarningCode = /** The directory holds no `SKILL.md`. */ "no_skill_file" /** `SKILL.md` has no well-formed `---` fenced frontmatter block: no opening fence, an opening fence never closed by a whole-line `---`, a tool list (`allowed-tools` / `disallowed-tools`) whose value the reader cannot read as a list, a key whose value opens a quote, a `[` or a `{` it does not close, a value that is not a nested block followed by an indented `key:` line, a tool-list key written anywhere but flush left (in a nested block, or after a closing quote or `]` on a key's line), or a flush-left line that is no key line. */ | "no_frontmatter" /** Required `name` absent. */ | "missing_name" /** `name` breaks the charset/length rule. */ | "invalid_name" /** `name` disagrees with the directory name (the spec requires they match). */ | "name_mismatch" /** Required `description` absent. */ | "missing_description" /** `description` exceeds the spec cap. */ | "description_too_long" /** An attachment was left out (non-text bytes, over budget, unreadable, or resolving outside the skill). */ | "attachment_skipped" /** A file or directory entry existed but could not be read/resolved. */ | "read_failed" /** A skill directory or its `SKILL.md` resolves outside {@link SkillsDirectoryOptions.containWithin} — skipped. */ | "outside_root"; /** One non-fatal diagnostic from a load. Safe to log: no file contents, only names/reasons. */ export interface SkillsDirectoryWarning { code: SkillsDirectoryWarningCode; /** Directory name of the skill this concerns, relative to the scanned root. */ skill: string; /** Human-readable detail (names the offending field/file and what was done about it). */ detail: string; } export interface SkillsDirectoryOptions { /** Sink for non-fatal diagnostics. Absent ⇒ warnings are dropped and the load still proceeds. */ onWarning?: (warning: SkillsDirectoryWarning) => void; /** * Total decoded-text budget for ONE skill's attachments, in bytes (default 256 KiB). Files are taken in * path order until the budget is spent; the rest are skipped with a disclosed warning. Attachments also * ride a separate delivery cap at invoke time, so a generous value here does not enlarge the prompt. */ maxAttachmentBytes?: number; /** * The directory this root's skills must physically lie inside. Declared ⇒ every skill directory, and every * `SKILL.md`, resolves (links followed) inside it or is skipped with an `outside_root` warning; the attachments * already lie inside their skill directory, so they are inside it too. Absent ⇒ each root entry is a mount the * deployment declared, trusted like the root itself. A root a third party writes (a repository's skills * directory) declares the repository root here, so a link in it cannot load a skill from elsewhere on disk. * Must name an existing directory; anything else throws, like an unreadable root. */ containWithin?: string; } /** * Load every `SKILL.md`-bearing subdirectory of `dir` into a {@link SkillSpec}, ready for * `TaskSpec.skills`. Results are ordered by directory name (deterministic across filesystems). * * Per-skill problems are NON-FATAL: a directory whose frontmatter breaks the spec (missing/invalid * `name`, missing/over-long `description`, no fenced block, no `SKILL.md`) is SKIPPED and reported * through {@link SkillsDirectoryOptions.onWarning}, so one malformed skill cannot take down a whole * library. The root itself is different — an unreadable/non-directory root THROWS, because silently * returning `[]` for a mistyped path would look exactly like "this deployment has no skills". */ export declare function createSkillsFromDirectory(dir: string, options?: SkillsDirectoryOptions): SkillSpec[];