UNPKG

agentfootprint

Version:

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

218 lines • 11.5 kB
/** * 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. */ import { cacheStrategyFor } from '../../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). */ export 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 }), }; } /** * 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. */ export 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 }), }; } 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}. */ export 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 : 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); }; } /** 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). */ export 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 }; } //# sourceMappingURL=skillBrains.js.map