agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
167 lines • 7.78 kB
JavaScript
;
/**
* toolChoice/pick — ask the classifier which tool, and decide what is served.
*
* Pattern: a pure question builder (`toolChoiceQuestion`) + one async asker
* that never throws (`pickTools`) + one pure narrowing decision
* (`narrowServed`) — the `findings/judge.ts` split, with the
* serving half kept pure so every skip reason is testable without
* a run.
* Role: core/ layer. Reached ONLY from `slots/buildToolsSlot.ts ·
* composeStage` through `import()` under `.toolChoice()` — the
* optional-family law of docs-next's site budget (the
* `toolCalls.ts · judgeLanded` precedent): an agent that never
* asked never loads this module.
*
* WHAT IS ASKED. One `choice` question, id `TOOL_CHOICE_QUESTION`, whose
* criteria are the OFFERED tools by name with their own descriptions —
* declared before the call, the same law as declared tags and routes. The
* state is the user's message and, when a skill is active, its id; nothing
* the library wrote. The doors are not candidates: a door is served whatever
* the classifier says, so asking about it would only dilute the distribution.
*
* WHAT IS DECIDED. `narrowServed` serves the top-N of the provider's ranking
* plus the doors, in the merged wire's order, and the full wire — with the
* reason on the row — when the classifier failed or scored fewer than N
* names (`unavailable`), when fewer than N + 1 candidates were offered
* (`too-few` — there is nothing to leave out), on the call right after a
* miss (`after-miss` — the model just showed the ranking was wrong), and on
* the wrap-up call (`wrap-up` — every tool comes off at assembly anyway).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.alwaysServedNames = exports.narrowServed = exports.pickTools = exports.toolChoiceQuestion = void 0;
const types_js_1 = require("../../../classify/types.js");
const types_js_2 = require("./types.js");
const TOOL_CHOICE_INSTRUCTIONS = 'The state is the request the agent is working on and, when one is set, the id of the skill ' +
'it is currently following. Pick the ONE tool, by name, whose description answers the current ' +
'step of this request. Pick by what each description says the tool does, not by its name.';
/**
* Build the classifier request for one call. Pure. `criteria` is `{ name:
* description }` over the candidates in offered order; an empty description
* is sent as the empty string, never invented.
*/
function toolChoiceQuestion(userMessage, currentSkillId, candidates) {
const criteria = {};
for (const c of candidates)
criteria[c.name] = c.description;
return {
state: { message: userMessage, ...(currentSkillId !== undefined && { skill: currentSkillId }) },
questions: {
[types_js_2.TOOL_CHOICE_QUESTION]: {
type: 'choice',
instructions: TOOL_CHOICE_INSTRUCTIONS,
criteria,
},
},
};
}
exports.toolChoiceQuestion = toolChoiceQuestion;
/**
* Ask the classifier once. Never throws: a provider failure, a network
* error, an abort, a reply with no `choice` answer under the question id —
* all come back as `ok: false` with the latency spent, because the pick is
* advisory and its absence must never cost the run its answer.
*/
async function pickTools(classifier, input, signal) {
const request = toolChoiceQuestion(input.userMessage, input.currentSkillId, input.candidates);
const startedAt = Date.now();
try {
const result = await classifier.classify(request, signal);
return pickFrom(result, classifier.name, input.candidates);
}
catch (err) {
return {
ok: false,
classifier: { name: classifier.name },
...(err instanceof types_js_1.ClassifierError && err.status !== undefined && { status: err.status }),
message: err instanceof Error ? err.message : String(err),
latencyMs: Date.now() - startedAt,
};
}
}
exports.pickTools = pickTools;
/**
* The provider's answer as a pick — field for field. The ranking IS the
* distribution, highest first, ties in offered order; a candidate the
* provider did not score is absent (the `classifierScorer` law — a 0 nobody
* sent would be one the library inferred). `chosen` is the provider's
* `choice` when it names an offered tool, never an argmax the library took.
*/
function pickFrom(result, classifierName, candidates) {
const answer = result.answers[types_js_2.TOOL_CHOICE_QUESTION];
if (answer === undefined || answer.type !== 'choice') {
throw new types_js_1.ClassifierError(`toolChoice: no 'choice' answer under '${types_js_2.TOOL_CHOICE_QUESTION}' in the reply`);
}
const scored = [];
candidates.forEach((c, offeredAt) => {
const p = answer.probabilities[c.name];
if (p !== undefined)
scored.push({ name: c.name, score: p, offeredAt });
});
// Highest first; `offeredAt` breaks ties so the order is the offer's, not the sort's.
scored.sort((a, b) => b.score - a.score || a.offeredAt - b.offeredAt);
const ranked = scored.map(({ name, score }) => ({ name, score }));
const known = candidates.some((c) => c.name === answer.choice);
return {
ok: true,
classifier: { name: classifierName, model: result.model },
ranked,
...(known && { chosen: answer.choice }),
confidence: answer.confidence,
...(result.usage !== undefined && { usage: result.usage }),
latencyMs: result.latencyMs,
};
}
/**
* Decide what is served for one call. Pure. `wire` is the merged list's
* names in order; `doors` every name that is served whatever the ranking
* says (`ALWAYS_SERVED_TOOLS` plus the app's own); `top` is N, or
* `undefined` for an advisory-only agent (the full wire, `narrowed: false`,
* no reason — nothing was skipped because nothing was asked).
*/
function narrowServed(input) {
const { wire, doors, pick, top, iteration, prior, wrapUpAsked } = input;
if (top === undefined)
return { served: wire, narrowed: false };
const skip = (reason) => ({
served: wire,
narrowed: false,
narrowedSkipped: reason,
});
if (wrapUpAsked)
return skip('wrap-up');
if (input.toolsOff === true)
return skip('evidence-revision');
if (outcomeAt(prior, iteration - 1)?.miss !== undefined)
return skip('after-miss');
const candidates = wire.filter((name) => !doors.has(name));
if (candidates.length < top + 1)
return skip('too-few');
if (!pick.ok || pick.ranked.length < top)
return skip('unavailable');
const keep = new Set(pick.ranked.slice(0, top).map((r) => r.name));
return {
served: wire.filter((name) => doors.has(name) || keep.has(name)),
narrowed: true,
};
}
exports.narrowServed = narrowServed;
/**
* The outcome row filed for exactly `iteration`, or nothing — either no
* call has run there yet, or that call had no pick and so filed no outcome
* (an intervening no-pick iteration must never resurrect an older miss).
*/
function outcomeAt(rows, iteration) {
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
if (row?.kind === 'outcome' && row.iteration === iteration)
return row;
}
return undefined;
}
/** Every name served whatever the ranking says: the framework's doors plus the app's own. */
function alwaysServedNames(extra) {
return new Set([...types_js_2.ALWAYS_SERVED_TOOLS, ...(extra ?? [])]);
}
exports.alwaysServedNames = alwaysServedNames;
//# sourceMappingURL=pick.js.map