UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

205 lines • 9.65 kB
/** * defineSkill — sugar for LLM-activated Injections that target both * system-prompt + tools. * * A Skill is a bundle of (1) a body of guidance and (2) optionally some * tools. The LLM decides when a Skill is needed by calling a designated * activation tool — by default `read_skill(<id>)`. * * Activation is about the BODY. A Skill's tools are registered up front * and callable from iteration 1 unless the Skill sets * `autoActivate: 'currentSkill'` — see that option. * * **Activation lifetime: the rest of the turn.** Once the model picks a * skill with `read_skill`, the activation lasts until the turn ends — every * later iteration of that `run()` keeps the body active, and there is no * mid-turn deactivation (nothing removes an id from * `ctx.activatedInjectionIds`; the next `run()` starts clean). This is * deliberate: the cache layer's skill history and the context ledger both * lean on the cumulative property. A skill that should stop applying * mid-turn is a `rule` trigger or a `skillGraph()` route, not a read_skill * pick. * * Produces an `Injection` with: * - flavor: `'skill'` * - trigger: `{ kind: 'llm-activated', viaToolName: 'read_skill' }` * - inject: `{ systemPrompt: body, tools }` * * The Agent integration auto-attaches the `read_skill` tool when one * or more Skills are present. When the LLM calls * `read_skill('billing')`, the engine adds `'billing'` to * `ctx.activatedInjectionIds`; the next iteration's evaluator * matches this Skill's `id`, activates it, and the body lands in the * slot subflows (plus the tools, for an `autoActivate` Skill — every * other Skill's tools were already there). * * @example * const billingSkill = defineSkill({ * id: 'billing', * description: 'Use for refunds, charges, billing questions.', * body: 'When handling billing: confirm identity first, then…', * tools: [refundTool, chargeHistoryTool], * }); */ import { plainLineProblem } from '../../lib/plainLine.js'; import { assertKnownOptions } from '../optionKeys.js'; import { cachePolicyMetadata } from '../../cache/applyCachePolicy.js'; import { validateSkillSteps } from '../skillSteps.js'; import { assertArtifactVocabulary } from '../skillVocabulary.js'; /** * Resolve `surfaceMode: 'auto'` to a concrete mode based on provider * + model. The defaults match the per-provider attention profile * documented in the Skills, explained essay: * * - Claude >= 3.5 → 'both' (cheap to cache, high adherence) * - Claude pre-3.5 → 'tool-only' (recency-first more reliable) * - OpenAI / Bedrock / Ollama / Mock / unknown → 'tool-only' * * Pure function — no side effects. Consumers can call directly to * inspect what `'auto'` will resolve to in their stack. */ export function resolveSurfaceMode(provider, model) { const p = provider.toLowerCase(); if (p === 'anthropic') { // Match both naming styles in current use: // - claude-3-5-sonnet-..., claude-3.5-... // - claude-sonnet-4-..., claude-haiku-4-..., claude-opus-4-..., claude-4-... // Anything matching "Claude >= 3.5" gets 'both'; older Claudes get 'tool-only'. if (model && /(claude-3-5|claude-3\.5|claude-(?:opus-|sonnet-|haiku-)?[4-9])/i.test(model)) { return 'both'; } return 'tool-only'; } return 'tool-only'; } const SKILL_OPTION_KEYS = { id: true, description: true, title: true, body: true, tools: true, surfaceMode: true, autoActivate: true, steps: true, onSkip: true, produces: true, consumes: true, provider: true, model: true, cache: true, }; /** The unified facade consumes `type`; all skill options keep one owner. */ const SKILL_INJECTION_OPTION_KEYS = { ...SKILL_OPTION_KEYS, type: true }; /** The longest skill title a report prints. */ const MAX_SKILL_TITLE_CHARS = 60; /** * Validate a declared `title` — refused where the author wrote it. `undefined` * when none was declared; otherwise the trimmed title. */ function readSkillTitle(id, raw) { if (raw === undefined) return undefined; const at = `defineSkill(${id}): \`title\``; if (typeof raw !== 'string' || raw.trim() === '') { throw new Error(`${at} must be a non-empty string — or omit it; a report then prints the id.`); } const title = raw.trim(); const notPlain = plainLineProblem(at, title); if (notPlain !== undefined) throw new Error(notPlain); if (title.length > MAX_SKILL_TITLE_CHARS) { throw new Error(`${at} is ${title.length} characters; the limit is ${MAX_SKILL_TITLE_CHARS}.`); } if (title === id) { throw new Error(`${at} is the id itself — a title is the plain name a person reads; omit it.`); } return title; } export function defineSkill(opts) { return createSkill(opts, SKILL_OPTION_KEYS); } /** * Internal facade entry, deliberately absent from every public barrel. * Validate the original declaration, preserving inherited options and getter * receivers. Only this entry consumes the unified factory's discriminant. */ export function defineSkillInjection(opts) { return createSkill(opts, SKILL_INJECTION_OPTION_KEYS); } /** One validation/construction path for named and discriminated declarations. */ function createSkill(opts, optionKeys) { if (!opts.id || opts.id.trim().length === 0) { throw new Error('defineSkill: `id` is required and must be non-empty.'); } if (!opts.description || opts.description.length === 0) { throw new Error(`defineSkill(${opts.id}): \`description\` is required (LLM uses it to decide when to activate).`); } if (!opts.body || opts.body.length === 0) { throw new Error(`defineSkill(${opts.id}): \`body\` is required.`); } assertKnownOptions(`defineSkill(${opts.id})`, opts, optionKeys); const title = readSkillTitle(opts.id, opts.title); // Steps checkup (9.18.0) — all the data is in hand HERE, so every step // refusal happens here: unknown tool, empty note/tool, steps:[], steps // without tools, onSkip without steps. See skillSteps.ts, the grammar owner. validateSkillSteps(opts.id, { ...(opts.steps !== undefined && { steps: opts.steps }), ...(opts.onSkip !== undefined && { onSkip: opts.onSkip }), toolNames: new Set((opts.tools ?? []).map((t) => t.schema.name)), }); // The artifact vocabularies (9.25.0) — same law, same authoring point: a // blank kind, an empty list or a repeat is refused HERE, where the // declaration is written. Whether a consumed kind is SATISFIABLE needs the // whole agent and is checked at build (`skillVocabulary.ts`). assertArtifactVocabulary(`defineSkill(${opts.id})`, 'produces', opts.produces); assertArtifactVocabulary(`defineSkill(${opts.id})`, 'consumes', opts.consumes); return Object.freeze({ id: opts.id, description: opts.description, // Top-level, not a `metadata` key: at least one path rebuilds `metadata` // wholesale (`toolsFromActiveSkill`), and a top-level field survives every // `{ ...injection }` spread (the `templated` precedent). ...(title !== undefined && { title }), flavor: 'skill', trigger: { kind: 'llm-activated', viaToolName: 'read_skill', }, inject: { systemPrompt: opts.body, ...(opts.tools && opts.tools.length > 0 && { tools: opts.tools }), }, // Skill-specific options live in metadata. The engine reads them // when present; absent metadata = current behavior. `surfaceMode` and // `autoActivate` are read at runtime. // // `cache` also rides this bag when a caller sets it. metadata: Object.freeze({ surfaceMode: opts.surfaceMode ?? 'auto', ...(opts.autoActivate && { autoActivate: opts.autoActivate }), // The skill's brain (9.19.0) — rides the metadata bag like every other // per-skill option. NEVER projected (`projectActiveInjection`'s // allow-list does not carry it), so the live provider object stays on // the closure-held list and never crosses a scope boundary. // `Agent.build()` folds + validates it (`skillBrains.ts`). ...(opts.provider !== undefined && { provider: opts.provider }), ...(opts.model !== undefined && { model: opts.model }), // The procedure, as data (9.18.0). `Agent` folds these into one frozen // StepPlan map at build; the engine ignores them everywhere else, so a // consumer reading skills off this bag sees exactly what was declared. ...(opts.steps && opts.steps.length > 0 && { steps: Object.freeze(opts.steps.map((s) => Object.freeze({ ...s }))), onSkip: opts.onSkip ?? 'advance', }), // The artifact vocabularies (9.25.0) — recorded as declared, on the same // bag every other per-skill option rides. Build-time checks read them // from here, and so will a lens; the engine never does. Absent when not // declared, so a skill without them serializes byte-identically. ...(opts.produces !== undefined && { produces: Object.freeze([...opts.produces]) }), ...(opts.consumes !== undefined && { consumes: Object.freeze([...opts.consumes]) }), ...cachePolicyMetadata('skill', opts.cache), }), }); } //# sourceMappingURL=defineSkill.js.map