agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
225 lines • 11.9 kB
JavaScript
;
/**
* skillBrains — per-skill model switching (9.19.0). ONE owner of the brains
* fold: the declared shapes (`ProviderChoice`, `EscalationPolicy`), the
* build-time fold + check-up over BOTH declaration homes, and the runtime
* `brainFor` consult callLLM makes.
*
* "THE CURSOR PICKS THE BRAIN." A skill graph already decides WHERE the run
* is (the one cursor); this feature lets that same position decide WHO
* answers — a cheap triage skill on a small model, the refund skill on the
* strong one — with zero new stages: `callLLM` consults `brainFor(cursor,
* escalated)` once at the top and resolves field by field down the stated
* precedence chain:
*
* **escalation > per-skill brain > `.configure()` `resolvedModel` >
* build-time default** — the more specific context wins (evidence > node
* > run > build). Field-by-field at the call site: `provider =
* brain?.provider ?? deps.provider`; `model = brain?.model ??
* (runConfigured ? scope.resolvedModel : undefined) ?? deps.model`;
* `cacheStrategy = brain?.cacheStrategy ?? deps.cacheStrategy`. A brain
* naming only a MODEL inherits the agent's provider; a brain naming only a
* foreign PROVIDER may not inherit the agent's model id (that id belongs
* to another vendor's namespace and would fail mid-turn, on exactly the
* iteration the cursor enters the skill) — refused at build instead.
*
* TWO declaration homes, one meaning: `defineSkill({ provider, model })`
* keeps the choice beside the skill it serves; `SkillGraphOptions.providers`
* keeps a fleet's choices in one place at the mount. The same id declared in
* both with different choices is refused naming both homes — configuration
* that disagrees with itself must not pick a silent winner.
*
* ESCALATE-ON-EVIDENCE. `escalation: { provider, model?, afterRefusals: N }`
* — when the gate refuses N routing picks in ONE turn (`skill.rejected` —
* reachability, posture, OR a self-call: all three refusal arms count, and
* they are real recorded refusals, never vibes), the rest
* of the turn runs on the escalation brain, `skill.escalated` goes on the
* record once, and the next turn's seed de-escalates. The loop the model is
* flubbing gets the bigger brain until the turn resolves.
*
* Vendor-neutral by construction: a brain is an `LLMProvider` OBJECT (any
* port implementation), never a vendor name this library resolves.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.describeServingBrain = exports.buildBrainFor = exports.foldSkillBrains = exports.skillBrainOf = void 0;
const cacheStrategyFor_js_1 = require("../../cache/cacheStrategyFor.js");
/** The per-skill brain declared on `defineSkill({ provider, model })`,
* read off the metadata bag (the engine-ignores-unknown precedent —
* `projectActiveInjection` never projects it, so the live provider object
* stays on the closure-held list and never crosses a scope boundary). */
function skillBrainOf(injection) {
const meta = injection.metadata;
if (meta?.provider === undefined && meta?.model === undefined)
return undefined;
return {
// `model` alone is legal — the agent's own provider, another model.
...(meta.provider !== undefined && { provider: meta.provider }),
...(meta.model !== undefined && { model: meta.model }),
};
}
exports.skillBrainOf = skillBrainOf;
/**
* Fold both declaration homes into one frozen map, running every build-time
* refusal (the check-up). Returns `undefined` when nothing was declared —
* the zero-cost gate every caller branches on.
*/
function foldSkillBrains(args) {
const fromOptions = new Map(Object.entries(args.providers ?? {}));
const fromSkills = new Map();
for (const inj of args.injections) {
if (inj.flavor !== 'skill')
continue;
const brain = skillBrainOf(inj);
if (brain)
fromSkills.set(inj.id, brain);
}
const declaredAnything = fromOptions.size > 0 ||
fromSkills.size > 0 ||
args.escalation !== undefined ||
args.decider !== undefined;
if (!declaredAnything)
return undefined;
// A brain with no cursor to pick it: "the cursor picks the brain", and an
// agent without a graph has no cursor. Refused rather than silently inert.
if (!args.graphMounted) {
const ids = [...new Set([...fromOptions.keys(), ...fromSkills.keys()])];
const what = ids.length > 0
? `per-skill brains (${ids.map((id) => `'${id}'`).join(', ')})`
: args.escalation !== undefined
? 'an escalation policy'
: 'a routing decider';
throw new Error(`Agent.build(): ${what} declared, but no skill graph is mounted — the cursor picks ` +
`the brain, and without .skillGraph() there is no cursor, so the declaration would ` +
`never serve a single call. Mount a graph, or drop the provider/model declarations.`);
}
if (args.nodeIds === undefined) {
throw new Error(`Agent.build(): per-skill brains / escalation / decider were declared, but the mounted ` +
`graph object carries no \`nodes\` — the brain check-up cannot tell a skill id from a ` +
`typo against it. Build the graph with this version's skillGraph() (every graph it ` +
`builds carries nodes), or drop the declarations.`);
}
// Foreign-provider-without-model: decidable at build, guaranteed wrong at
// run (the agent's model id belongs to another vendor's namespace).
const requireModelOnForeign = (choice, where) => {
if (choice.model !== undefined)
return;
if (choice.provider === undefined || choice.provider.name === args.agentProviderName)
return;
throw new Error(`Agent.build(): ${where} names provider '${choice.provider.name}' without a model. The ` +
`run's configured model belongs to '${args.agentProviderName}' and cannot be resolved ` +
`against a different provider — the call would fail mid-turn, on exactly the ` +
`iteration this brain first serves. Name the model for it (inheritance down the ` +
`chain stays legal when the provider matches the agent's).`);
};
// Merge the two homes. Same id + same choice = fine (one fact said twice);
// different choices = a disagreement no silent winner may resolve.
const bySkill = new Map();
for (const [skillId, choice] of fromSkills)
bySkill.set(skillId, choice);
for (const [skillId, choice] of fromOptions) {
const other = fromSkills.get(skillId);
if (other !== undefined) {
const sameProvider = other.provider === choice.provider;
const sameModel = other.model === choice.model;
if (!sameProvider || !sameModel) {
throw new Error(`Agent.build(): skill '${skillId}' declares a brain in BOTH homes with different ` +
`choices — defineSkill({ provider: ${describeChoice(other)} }) and ` +
`skillGraph options.providers['${skillId}'] = ${describeChoice(choice)}. Two ` +
`homes may repeat one choice; they may not disagree. Keep one declaration, or ` +
`make them identical.`);
}
}
bySkill.set(skillId, choice);
}
for (const [skillId, choice] of bySkill) {
if (!args.nodeIds.has(skillId)) {
throw new Error(`Agent.build(): a brain is declared for skill '${skillId}', but the mounted graph ` +
`has no node with that id — the cursor can never land there, so the brain would ` +
`never serve a call. Fix the id, wire the skill into the graph, or drop the ` +
`declaration.`);
}
// `model` alone (provider undefined in the defineSkill home) is the
// agent's own provider — nothing foreign to check.
if (choice.provider !== undefined) {
requireModelOnForeign(choice, `skill '${skillId}''s brain`);
}
}
if (args.escalation !== undefined) {
const n = args.escalation.afterRefusals;
if (!Number.isInteger(n) || n < 1) {
throw new Error(`Agent.build(): escalation.afterRefusals must be an integer ≥ 1 (got ${String(n)}). ` +
`It counts recorded gate refusals (skill.rejected) in one turn before the flip — ` +
`0 would mean "always escalated", which is not escalation but a different default ` +
`brain; declare that as the agent's provider/model instead.`);
}
requireModelOnForeign(args.escalation, 'the escalation brain');
}
if (args.decider !== undefined) {
requireModelOnForeign(args.decider, 'the routing decider');
}
return {
bySkill,
...(args.escalation !== undefined && { escalation: args.escalation }),
...(args.decider !== undefined && { decider: args.decider }),
};
}
exports.foldSkillBrains = foldSkillBrains;
function describeChoice(c) {
const provider = c.provider !== undefined ? `provider '${c.provider.name}'` : 'agent provider';
return c.model !== undefined ? `${provider}, model '${c.model}'` : provider;
}
/** Build the runtime consult. See {@link BrainFor}. */
function buildBrainFor(args) {
const strategyFor = (provider, model) => {
const inheritsProvider = provider === undefined || provider === args.agentProvider;
if (inheritsProvider && args.agentCacheStrategyIsOverride === true)
return args.agentCacheStrategy;
return inheritsProvider &&
(model === undefined ||
model === args.agentModel ||
typeof args.agentProvider.promptCaching !== 'function')
? args.agentCacheStrategy
: (0, cacheStrategyFor_js_1.cacheStrategyFor)(provider ?? args.agentProvider, model ?? args.agentModel);
};
const bySkill = new Map();
for (const [skillId, choice] of args.brains.bySkill) {
bySkill.set(skillId, {
provider: choice.provider,
...(choice.model !== undefined && { model: choice.model }),
cacheStrategy: strategyFor(choice.provider, choice.model),
via: 'skill',
skillId,
});
}
const escalation = args.brains.escalation
? {
provider: args.brains.escalation.provider,
...(args.brains.escalation.model !== undefined && {
model: args.brains.escalation.model,
}),
cacheStrategy: strategyFor(args.brains.escalation.provider, args.brains.escalation.model),
via: 'escalation',
}
: undefined;
return (activeCursor, escalated) => {
// Evidence outranks the node: once the turn escalated, EVERY remaining
// call of the turn runs on the escalation brain, whatever the cursor.
if (escalated && escalation !== undefined)
return escalation;
if (activeCursor === undefined)
return undefined;
return bySkill.get(activeCursor);
};
}
exports.buildBrainFor = buildBrainFor;
/** Describe the brain that WOULD serve a cursor pre-escalation — the
* `skill.escalated` event's honest `from` field, resolved by the same
* precedence chain callLLM applies (skill brain > configured > default). */
function describeServingBrain(args) {
const brain = args.cursor !== undefined ? args.brains.bySkill.get(args.cursor) : undefined;
const provider = brain?.provider?.name ?? args.agentProviderName;
const model = brain?.model ?? args.resolvedModel ?? args.defaultModel;
return { provider, model };
}
exports.describeServingBrain = describeServingBrain;
//# sourceMappingURL=skillBrains.js.map