agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
238 lines (237 loc) • 12.7 kB
TypeScript
/**
* skillsFromDir — load Skills that were AUTHORED AS FILES.
*
* The just-in-time half of skills already ships: `defineSkill` produces an
* Injection whose `description` is all the model sees until it calls
* `read_skill(<id>)`, at which point the body enters the context. What did not
* ship was the other half — a way to keep those bodies where prose belongs, in
* files, next to the code they describe, reviewable in a diff.
*
* This is that loader, and nothing more. It reads a directory of `SKILL.md`
* files and hands each one to `defineSkill`. The progressive disclosure, the
* activation tool, the slot routing — all unchanged, all upstream of here.
*
* skills/
* billing/SKILL.md
* refunds/SKILL.md
*
* ---
* name: billing
* description: Use for refunds, charges and billing questions.
* ---
* When handling billing: confirm identity first, then …
*
* const skills = await skillsFromDir('./skills');
* const agent = Agent.create({ provider, model }).skills({ list: () => skills }).build();
*
* The frontmatter is the disclosure stub (`name` + `description` — what the
* model reads when deciding), and everything after the closing fence is the
* body (what it reads after deciding). That is the same file convention Claude
* Code made familiar, so a skill folder is portable between the two.
*
* ── What a SKILL.md can carry (9.36.0: the whole runbook) ────────────────────
* A runbook you already have on disk is three things — what to do (prose),
* what to do it WITH (tools), and in what order (steps). This door used to
* carry the first and drop the other two, so the one thing the library exists
* to say ("extract the workflow you already have") arrived two-thirds empty.
* All three now cross:
*
* ---
* name: billing
* description: Use for refunds, charges and billing questions.
* tools: lookup_order, issue_refund
* steps:
* - lookup_order: look up the order before touching money
* - issue_refund: refund only what the lookup found
* onSkip: hold
* ---
* When handling billing: confirm identity first, then …
*
* const skills = await skillsFromDir('./skills', {
* tools: [lookupOrder, issueRefund], // the real Tools, from YOUR code
* });
*
* The remaining limits, and why each one is a decision rather than an
* oversight:
*
* - **the file names a tool; it never defines one.** A tool is code with an
* `execute`, and a markdown file has none. `tools:` is a list of NAMES,
* resolved against the `tools` registry the CALLER passes — so the file
* can only ever pick from capabilities you already handed in, and reading a
* directory can never introduce one. See "why this is not a code-execution
* vector" below.
* - **a name the registry does not carry is refused BY NAME at load** —
* never a skill that quietly loads without the tool it asked for, because
* that skill runs, sounds confident, and cannot do the job.
* - **no per-step `produces` / `consumes`** — the artifact vocabularies
* (9.25.0) are a build-time contract between skills, not runbook prose;
* declare them with `defineSkill` where the rest of the agent is declared.
* - **no `autoActivate`** — a directory does not decide the agent's tool
* posture. Say it once on the agent with `.toolsFromActiveSkill()`
* (9.36.0), which scopes every skill's tools to that skill's activation.
* Without it, a loaded skill's tools are on the wire from iteration 1, the
* same as any hand-written `defineSkill({ tools })`.
* - **no per-file `surfaceMode` or `cache`** — `surfaceMode`
* is settable for the WHOLE directory via `opts`, all of them or none; the
* others take `defineSkill`'s defaults.
* - unknown frontmatter keys are still IGNORED, not rejected, so a file
* carrying another tool's metadata still loads here. `name`,
* `description`, `tools`, `steps`, `onSkip` and `routes` are the KNOWN
* keys — a file that used one of those six for something else is the one
* case a release can change, and it changes it loudly.
*
* ── The routing, and the door that reads it (9.43.0) ─────────────────────────
* A runbook says what to do, what to do it with, in what order — and where the
* work goes NEXT. That last part is the graph, and until now it had to be
* hand-wired in code even when the files said it plainly. `routes:` closes it:
*
* ---
* name: billing
* tools: lookup_order, issue_refund
* routes:
* - escalation: on issue_refund status=denied
* - receipts: on issue_refund
* ---
*
* const runbook = await runbookFromDir('./skills', { tools: [...] });
* const graph = skillGraph({ ...runbook, start: 'billing' });
*
* TWO DOORS, one truth: `skillsFromDir` returns skills and REFUSES a file that
* declares `routes:` (a door that dropped the routing would hand back a graph
* you believed was declared on disk); {@link runbookFromDir} returns
* `{ skills, steps }`. Same law as `tools:` — the file PICKS (a route names a
* skill id this directory declares; an unknown id is refused at load, by name,
* listing what is available), it never DEFINES. A guard is one of the two DATA
* conditions a route already has (`on <tool_name>`, `status=<outcome>`), and
* nothing else: a `when` predicate is CODE, no file can carry code, and that
* conditional stays in your source. See `skillsFromDirRoutes.ts` for the whole
* grammar and every refusal.
*
* A worked example feeding this into a graph:
* `examples/features/47-skills-from-dir-graph.ts`.
*
* ── Why authorship is decided here, at load time ─────────────────────────────
* A Skill body is *instructions to a model*. Where it came from is therefore a
* security property, not a convenience: content fetched at run time from
* somewhere else is content someone else can change after you reviewed it.
* This loader accepts a local directory and nothing else — a URL is refused BY
* NAME rather than fetched — because "these files are mine" is a claim you can
* only make about a path on your own disk at build time. Each file is read
* ONCE, here; a later edit does not reach a run already in flight.
*
* ── Why carrying tools is not a code-execution vector ───────────────────────
* Nothing in a SKILL.md is evaluated, imported, required or resolved as a
* path. `tools:` is a list of strings, and the only thing a string can do here
* is MATCH — against `tool.schema.name` in a registry the caller constructed in
* their own source, from their own imports. A hostile file can therefore ask
* for a tool you already gave the agent (and it would have to guess the name);
* it can never name a module, widen the agent's capabilities, or cause one byte
* of new code to run. The set of things this directory can do is a SUBSET of
* what the calling file already decided to do, which is the same property the
* local-path rule above buys for bodies.
*
* Node-only. `node:fs/promises` and `node:path` are imported lazily inside the
* call, the same gating `tool-lint/cli.ts` uses: this module is reachable
* from the `agentfootprint/context` barrel, and a TOP-LEVEL node:fs
* import detonates a browser bundle at module-eval even when nothing calls it.
*/
import type { Injection } from './types.js';
import type { Tool } from '../tools/tools.js';
import { type SurfaceMode } from './factories/defineSkill.js';
import type { SkillGraphStep } from './skillGraph.js';
export interface SkillsFromDirOptions {
/**
* Where a loaded skill's body lands once activated. Defaults to
* `defineSkill`'s own default, `'auto'`. See {@link SurfaceMode}.
*/
readonly surfaceMode?: SurfaceMode;
/**
* The tools a file may NAME (9.36.0) — the resolution registry for every
* `tools:` declaration in the directory, matched by `tool.schema.name`.
*
* This is the half a markdown file cannot supply. The file picks; you decide
* what there is to pick FROM, in your own source, from your own imports. A
* declared name that is not in here is refused at load, naming the file, the
* name, and what this registry does carry.
*
* Pass every tool the directory might use; a tool nothing names is simply
* unused (no skill gets it, and no error). Omit the option entirely on a
* prose-only directory — a file that declares `tools:` with no registry
* passed is refused rather than loaded without them.
*/
readonly tools?: readonly Tool[];
}
/**
* A whole runbook directory, read as the two things a graph is made of
* (9.43.0): the skills, and the edges between them.
*
* Spreads straight into the graph — the field names are the graph's own, so
* nothing has to be translated at the call site:
*
* const runbook = await runbookFromDir('./skills', { tools });
* const graph = skillGraph({ ...runbook, start: 'triage' });
*/
export interface DirRunbook {
/** Every `SKILL.md` under the directory, as Skill Injections — byte-identical
* to what `skillsFromDir` returns for the same directory. */
readonly skills: readonly Injection[];
/**
* The declared edges, ready for `skillGraph({ steps })`. In skill-name order,
* then file order, so a graph built from a directory is stable.
*
* NAME NOTE, because two different things are spelled `steps` in this
* library: a file's own `steps:` key is that SKILL's tool sequence (which
* rides on the skill, unchanged); these `steps` are the GRAPH's edges, read
* from each file's `routes:` key. They never meet.
*/
readonly steps: readonly SkillGraphStep[];
}
/**
* Load every `SKILL.md` under `dir` as a Skill Injection.
*
* Two layouts are accepted, and they can be mixed:
* - `dir/<anything>/SKILL.md` — one folder per skill (the portable layout,
* and the one to prefer: the folder can hold the skill's other assets).
* - `dir/SKILL.md` — the directory IS one skill.
*
* The returned array is sorted by skill name, so a chart built from it is
* stable regardless of the order the filesystem happened to hand back.
*
* @param dir - A local filesystem path. A URL (or any `scheme://` string, or a
* UNC network path) is refused by name — see the module header for why.
* @param opts - Applied uniformly to every loaded skill.
*
* @throws when `dir` is not a local path, does not exist, is not a directory,
* or contains no `SKILL.md` at all; when a file's frontmatter is malformed
* (the message names the file); when two files claim the same skill name
* (the message names both); or when a file names a tool the `tools` registry
* does not carry, or sequences a tool the file itself did not declare.
*/
export declare function skillsFromDir(dir: string, opts?: SkillsFromDirOptions): Promise<readonly Injection[]>;
/**
* Load a directory as a whole RUNBOOK (9.43.0) — the skills AND the edges
* between them.
*
* `skillsFromDir` reads what one skill is; this reads what the skills are TO
* EACH OTHER, from each file's `routes:` key, and hands back both halves ready
* for `skillGraph({ ...runbook, start })`. Everything else is identical: same
* layouts, same frontmatter, same tool registry, same refusals.
*
* The routing a file may carry is deliberately small, and the boundary is a
* security property rather than a limitation — see `skillsFromDirRoutes.ts`:
* a route NAMES a skill this directory declares (an unknown id is refused at
* load, by name, listing what is available — never a half-graph), and a guard
* is one of the two DATA conditions a route already has (`on <tool>`,
* `status=<outcome>`). A `when` predicate is code, so no file can express one;
* that conditional stays in your source, where `skillGraph({ steps })` takes it.
*
* @param dir - A local filesystem path. Same rule as `skillsFromDir`.
* @param opts - Applied uniformly to every loaded skill.
*
* @throws everything `skillsFromDir` throws, plus: a route to an id no
* `SKILL.md` in the directory declares (the message lists what is
* available), a guard the grammar cannot express (the message quotes the
* whole grammar), a guard naming a tool the file itself does not declare,
* and a file routing to the same skill twice.
*/
export declare function runbookFromDir(dir: string, opts?: SkillsFromDirOptions): Promise<DirRunbook>;