agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
331 lines • 18.4 kB
JavaScript
/**
* skillSteps — the procedure grammar (9.18.0). ONE owner of everything
* "steps as data": the types, the validation, the step pointer's shape and
* re-key rule, the `skip_step` integrity tool, and EVERY sentence the model
* reads about a procedure (the banner, the result suffixes, the skip
* sentences, the nudge). Stages and slots call in here; none of them owns
* a word of the grammar.
*
* WHAT A PROCEDURE IS. `defineSkill({ steps })` declares an ordered list of
* `{ tool, note }` pairs — "run these tools, in this order, for these
* reasons". The framework then OWNS sequence and scope at the protocol
* level: while a stepped skill holds the tenure, the tools slot offers only
* the current step's tool (plus every escape hatch — see below), so the
* model cannot call step 5's tool from step 3 because step 5's schema was
* never sent. No refusal machinery, no new gate: an unoffered tool is
* uncallable by construction.
*
* WHAT THE MODEL STILL OWNS: judgment inside the step. It can run the tool,
* skip it with a recorded reason (`skip_step`), work around it with an
* escape hatch, or stop and say why. The escape hatches are load-bearing
* and stay offered under narrowing: `read_skill`, `list_skills`, every
* OTHER active skill's tools, the baseline `.tool()` registry, provider
* tools. A procedure is a declared order, not a cage.
*
* THE POINTER. `AgentState.stepPointer` records where the procedure stands:
* `{ skillId, step, total, skipped }`, 1-based, `step === total + 1` means
* complete. It is strictly SUBORDINATE to the one skill-graph cursor: it
* lives and dies inside one skill's tenure, is re-keyed at the same stage
* the cursor truth lives (Evaluate), and never crosses runs. It can never
* move the cursor.
*
* CARRIER SHAPE (load-bearing): the pointer travels through scope and the
* mount mappers as a 0-or-1-element ARRAY (`StepPointerCarrier`), never as
* a bare object. footprintjs's `applyOutputMapping` shallow-merges bare
* object values field-by-field — and APPENDS nested arrays — so a re-keyed
* pointer mapped as an object would inherit the previous tenure's `skipped`
* entries. A top-level array under `arrayMerge: Replace` is set wholesale.
* `[]` = the feature is on and no procedure is active; the key is absent
* entirely on agents with no stepped skill (zero-cost-when-unused).
*/
import { STEP_NUDGE_FRAME_PREFIX } from '../lib/saidByPerson.js';
import { assertArtifactVocabulary } from './skillVocabulary.js';
/** The integrity tool's reserved name. Auto-attached (dispatchable) when
* ≥1 registered skill declares steps; OFFERED only while a stepped tenure
* is active and unfinished. */
export const SKIP_STEP_TOOL_NAME = 'skip_step';
// ─── Validation (the defineSkill checkup — refuse at the earliest point) ──
/**
* Validate a `steps` declaration against the skill's own tools. All the
* data is in hand at `defineSkill`, so every refusal happens there — the
* `body-unknown-tool` sibling.
*/
export function validateSkillSteps(skillId, opts) {
const where = `defineSkill(${skillId})`;
if (opts.steps === undefined) {
if (opts.onSkip !== undefined) {
throw new Error(`${where}: \`onSkip\` is legal only beside \`steps\` — it declares what a skipped ` +
`step does, and this skill declares no steps. Add \`steps\` or drop \`onSkip\`.`);
}
return;
}
if (opts.steps.length === 0) {
throw new Error(`${where}: \`steps: []\` declares nothing — say what you mean and omit the field. ` +
`A skill without steps is byte-identical to one that never heard of them.`);
}
if (opts.onSkip !== undefined && opts.onSkip !== 'advance' && opts.onSkip !== 'hold') {
throw new Error(`${where}: onSkip '${String(opts.onSkip)}' is not a policy this library has. The two ` +
`are 'advance' (record the skip and move on — the default) and 'hold' (record the ` +
`skip and keep the step current).`);
}
if (opts.toolNames.size === 0) {
throw new Error(`${where}: \`steps\` declares a tool sequence but this skill carries no \`tools\`. ` +
`Every step runs one of the skill's own tools — add them to \`tools\`, or drop ` +
`\`steps\`.`);
}
opts.steps.forEach((step, i) => {
const n = i + 1;
// The vocabularies ride the same declaration, so they are refused here
// too — one authoring point, one place a mistake is caught.
assertArtifactVocabulary(`${where} step ${n}`, 'produces', step?.produces);
assertArtifactVocabulary(`${where} step ${n}`, 'consumes', step?.consumes);
if (!step || typeof step.tool !== 'string' || step.tool.trim().length === 0) {
throw new Error(`${where}: step ${n} names no tool. Every step is { tool, note } — the tool is ` +
`what the step runs.`);
}
if (typeof step.note !== 'string' || step.note.trim().length === 0) {
throw new Error(`${where}: step ${n} ('${step.tool}') has no note. The note is the sentence the ` +
`model reads about WHY this step exists — a step without one is a force-march ` +
`instruction, and the note is the whole point.`);
}
if (!opts.toolNames.has(step.tool)) {
throw new Error(`${where}: step ${n} names '${step.tool}', which this skill's \`tools\` does not ` +
`carry — add the tool or fix the name. Steps may only sequence the skill's own ` +
`tools; everything else stays available as an escape hatch, unsequenced.`);
}
});
}
// ─── The build-time fold ──────────────────────────────────────────────
/** Read a skill injection's declared steps off its metadata bag (the
* `autoActivate` precedent — the engine ignores unknown keys). */
export function stepsOf(injection) {
const meta = injection.metadata;
if (!meta?.steps || meta.steps.length === 0)
return undefined;
return { steps: meta.steps, onSkip: meta.onSkip ?? 'advance' };
}
/**
* Fold every stepped skill into one frozen plan map. Runs once at
* `Agent` build; the map is threaded as closures — nothing rides
* `projectActiveInjection`, so the projection allow-list is untouched.
* Empty map ⇒ the feature is off everywhere (callers thread nothing).
*/
export function foldStepPlans(injections) {
const plans = new Map();
for (const inj of injections) {
if (inj.flavor !== 'skill')
continue;
const declared = stepsOf(inj);
if (!declared)
continue;
plans.set(inj.id, {
skillId: inj.id,
steps: declared.steps.map((s) => ({ tool: s.tool, note: s.note })),
toolNames: new Set(declared.steps.map((s) => s.tool)),
onSkip: declared.onSkip,
});
}
return plans;
}
// ─── Pointer helpers ──────────────────────────────────────────────────
/** Unwrap the carrier. `undefined` for an absent key, an empty carrier,
* or a value that is not a carrier at all. */
export function pointerOf(carrier) {
if (!Array.isArray(carrier) || carrier.length === 0)
return undefined;
const p = carrier[0];
return typeof p?.skillId === 'string' && typeof p?.step === 'number' ? p : undefined;
}
/** True while the procedure has steps left (`step <= total`). A completed
* pointer stays on scope as the record; nothing narrows or nudges on it. */
export function stepInProgress(ptr) {
return ptr !== undefined && ptr.step <= ptr.total;
}
/** The current step's declaration, when the procedure is in progress. */
export function currentStepOf(ptr, plan) {
return stepInProgress(ptr) ? plan.steps[ptr.step - 1] : undefined;
}
/**
* The tenure RE-KEY — Evaluate's half of the pointer discipline.
*
* `tenant` is who holds the tenure THIS iteration: the advanced cursor on
* graph agents, else the tail of `activatedInjectionIds` (the shipped
* `activeSkillId` notion). Tenant changed → a fresh pointer when the new
* tenant declares steps, else cleared. Tenant unchanged → pass-through.
* Always returns a carrier (possibly empty) — the caller writes it every
* iteration the feature is on, so downstream readers never see a stale one.
*/
export function rekeyStepPointer(args) {
const prior = pointerOf(args.prior);
if (args.tenant === undefined)
return [];
if (prior?.skillId === args.tenant)
return [prior];
const plan = args.stepPlanFor(args.tenant);
if (!plan)
return [];
return [{ skillId: args.tenant, step: 1, total: plan.steps.length, skipped: [] }];
}
// ─── The skip_step integrity tool ─────────────────────────────────────
/**
* DESCRIBE the `skip_step` tool — name, schema, and the placeholder result.
*
* A description, not a tool: this module owns every sentence the model reads
* about a procedure, and that includes this one, but building the framework's
* tool object would mean importing the framework's tool factory into the
* grammar. `skillTools.ts#buildSkipStepTool` does the wrapping (9.34.0).
*
* The tool is auto-attached to the dispatch registry when ≥1 registered skill
* declares steps (the `read_skill` auto-attach seam) — always dispatchable,
* but OFFERED (schema in the request) only while a stepped tenure is active
* and unfinished. Its execute returns a placeholder; the tool-calls stage
* overwrites the model-visible result with the authoritative sentence,
* exactly as the skill-graph gate overwrites `read_skill` refusals —
* bookkeeping lives where the batch order lives.
*/
export function skipStepDescriptor() {
return {
name: SKIP_STEP_TOOL_NAME,
description: 'Skip the current step of the active skill procedure, with the reason on the record. ' +
'Use it when the step cannot or should not run for this input.',
inputSchema: {
type: 'object',
properties: {
reason: {
type: 'string',
description: 'Why this step cannot or should not run. Goes on the record.',
},
},
required: ['reason'],
additionalProperties: false,
},
execute: () => 'skip_step noted. (Raw placeholder — the agent loop replaces this result with the ' +
'authoritative step record; reading it verbatim means the runner that dispatched ' +
'skip_step does not track step procedures.)',
};
}
// ─── Every sentence the model reads ───────────────────────────────────
// LENS · tool-description · request-ephemeral
// reads: the pointer and the step's declared note; prefixed onto a schema copy rebuilt for THIS request
// law: may omit, never deny; every clause anchored to the call it was composed on.
/** `[Step 3 of 6 — <note>] ` — prefixed onto the current step's tool
* description in the offer (a rebuilt schema copy, substituted by name). */
export function stepBannerPrefix(ptr, step) {
return `[Step ${ptr.step} of ${ptr.total} — ${step.note}] `;
}
// LENS · tool-description · request-ephemeral
// reads: the pointer and the plan's DECLARED onSkip policy — named as declared, never as a prediction
// law: may omit, never deny; every clause anchored to the call it was composed on.
/** The per-iteration `skip_step` offer description: names the current
* step, the remaining count, and the declared onSkip policy. */
export function skipStepDescription(ptr, plan) {
const step = plan.steps[ptr.step - 1];
const remaining = ptr.total - ptr.step + 1;
const policy = plan.onSkip === 'hold'
? 'the step stays current until it runs or the turn ends'
: 'the procedure moves to the next step';
return (`Skip step ${ptr.step} of ${ptr.total} ('${step.tool}' — ${step.note}) with a recorded ` +
`reason. ${remaining} step(s) remain in the '${ptr.skillId}' procedure. On skip, ` +
`${policy} (declared policy: '${plan.onSkip}').`);
}
/** Suffix for the `read_skill` result that activated a stepped skill:
* where the procedure starts. */
export function readSkillStepIntro(plan) {
const first = plan.steps[0];
return (` You are on step 1 of ${plan.steps.length}: ${first.note}. ` + `Its tool: \`${first.tool}\`.`);
}
// LENS · tool-result · persistent-history
// reads: the advanced pointer and the frozen plan, both handed in
// known gap: ' Now on step N of M' is present tense on a surface re-read all turn; listed as unrepaired
// in this folder's README ledger.
// law: may omit, never deny; every clause anchored to the call it was composed on.
/** Suffix for a completed step's tool result: done + what is next. */
export function stepAdvanceSuffix(next, plan) {
const done = next.step - 1;
if (next.step > next.total) {
return ` Step ${done} of ${next.total} done. All ${next.total} steps complete.`;
}
const coming = plan.steps[next.step - 1];
return (` Step ${done} of ${next.total} done. Now on step ${next.step} of ${next.total}: ` +
`${coming.note} (tool: \`${coming.tool}\`).`);
}
// LENS · tool-result · persistent-history
// reads: the pointer AFTER the skip was applied, and the plan — the verdict, never a prediction of it
// known gap: `skillSteps.ts` · skipNothingActiveSentence is present tense on a persistent surface; on the record as
// unrepaired in this folder's README ledger.
// law: may omit, never deny; every clause anchored to the call it was composed on.
/** The authoritative `skip_step` result under the `'advance'` policy. */
export function skipAdvanceSentence(skippedIndex, reason, next, plan) {
if (next.step > next.total) {
return (`Step ${skippedIndex} skipped: ${reason}. That was the last step — the ` +
`'${next.skillId}' procedure is complete (${next.skipped.length} step(s) skipped).`);
}
const coming = plan.steps[next.step - 1];
return (`Step ${skippedIndex} skipped: ${reason}. Now on step ${next.step} of ${next.total}: ` +
`${coming.note} (tool: \`${coming.tool}\`).`);
}
/** The authoritative `skip_step` result under the `'hold'` policy. */
export function skipHoldSentence(ptr, reason, plan) {
const step = plan.steps[ptr.step - 1];
return (`Skip of step ${ptr.step} recorded: ${reason}. Step ${ptr.step} of ${ptr.total} stays ` +
`current (declared policy 'hold') — run \`${step.tool}\`, or finish and say why.`);
}
/** Teaching result for `skip_step` with an empty reason — no event. */
export function skipNeedsReasonSentence() {
return ('skip_step was not applied: `reason` is required and must be non-empty — it goes on ' +
'the record. Say why this step cannot or should not run, then call skip_step again.');
}
/** Teaching result for `skip_step` outside a stepped tenure — no event. */
export function skipNothingActiveSentence() {
return ('skip_step has nothing to skip: no step procedure is active (or the active one is ' +
'already complete). Continue with your other tools, or finish.');
}
/** The remaining (unrun) steps — the `steps_unfinished` payload's list and
* the nudge's material. Under `'advance'` a skipped step is behind the
* pointer and therefore never "remaining". */
export function remainingStepsOf(ptr, plan) {
if (!stepInProgress(ptr))
return [];
return plan.steps
.map((s, i) => ({ index: i + 1, tool: s.tool, note: s.note }))
.filter((s) => s.index >= ptr.step);
}
// LENS · injected-turn · persistent-history
// reads: the unrun steps ← `skillSteps.ts` · remainingStepsOf, the owner of that fold
// the opening ← STEP_NUDGE_FRAME_PREFIX from src/lib/saidByPerson.ts (writer and recogniser share it)
// law: may omit, never deny; every clause anchored to the call it was composed on.
/**
* The one teaching nudge for a premature stop (at most once per turn).
*
* Written as an AUTHORED FRAME (9.86.0). `stepNudge` appends the result to
* `scope.history` wearing `role: 'user'`, so two things follow. First, it has
* to be recognisable as this library's own writing: it opens with
* {@link STEP_NUDGE_FRAME_PREFIX}, imported from the authorship registry that
* `isSaidByPerson` matches against, because the body lists a skill id and
* every unrun step's TOOL NAME — exactly the text a routing rule scanning
* history watches for, and before the opening was here such a rule fired on
* the library's own nudge.
*
* Second, it is re-read on every later call of the turn, so it may not speak
* in the present or issue a standing instruction: "Steps 2–3 have not run …
* Finish them" is false the moment they run, and it was still sitting in
* `history` when they did. Every clause now reports the call the nudge was
* written for, in the past tense, and the ask is stated as what THAT call was
* for. Pinned by `test/injection-engine/userTurnProducers.test.ts`, which
* runs the composed sentence through `unprovable`.
*/
export function nudgeTeachingMessage(ptr, plan) {
const remaining = remainingStepsOf(ptr, plan);
const span = remaining.length === 1
? `Step ${remaining[0].index}`
: `Steps ${remaining[0].index}–${remaining[remaining.length - 1].index}`;
const list = remaining.map((s) => `${s.index}: ${s.note} (\`${s.tool}\`)`).join('; ');
// "This message asked …" rather than "This call was for …" (9.86.1): the
// nudge persists in `history` past the call it was written for, and a bare
// `this call` resolves to whichever call re-reads it. The message is the
// one thing that stays where it is.
return (`${STEP_NUDGE_FRAME_PREFIX} — ${span} of '${ptr.skillId}' had not run when the answer ` +
`above was given: ${list}. This message asked for them to be run, or for the reason ` +
`they were not run.]`);
}
//# sourceMappingURL=skillSteps.js.map