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
JavaScript
/**
* 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