@sema-agent/core
Version:
Stateless, task-oriented AI agent core
84 lines (83 loc) • 5.25 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.** 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 refusal, no de-duplication. What an 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; a loader that also judged would be a second reader of the grammar.
*
* Frontmatter is parsed by a deliberately closed subset reader (no YAML dependency — the runtime
* dependency budget is a standing constraint): top-level `key: value` scalars, optional surrounding
* quotes, `#` comment lines, block scalars (`|` / `>` with their indentation + chomping indicators),
* flow sequences (`[a, b]`, including the multi-line form), and indented mapping bodies skipped
* wholesale. That covers every field the spec defines, and the same posture the memory-engine
* frontmatter reader already takes.
*/
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_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";
/** 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;
}
/**
* 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[];