@sema-agent/core
Version:
Stateless, task-oriented AI agent core
115 lines (114 loc) • 9.12 kB
TypeScript
/**
* 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[];