agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
121 lines • 5.88 kB
JavaScript
;
/**
* constrainedEnumPick — ONE model call that can only answer from a fixed
* list (9.19.0). The machinery extracted from `llmClassifier` so the tier-3
* decider (RouteTurn's out-of-band menu resolver) and the classifier share
* one enum discipline instead of two drifting copies:
*
* • When `forcedToolChoiceFor(provider, model)` permits it, the pick rides
* a forced synthetic tool whose single argument is `enum: allowed` —
* the same mechanism the `'tool-forced'` output strategy uses. The
* provider constrains generation; an answer outside the enum cannot be
* emitted at all.
* • On any other provider it is a strict single-line parse validated
* against the enum, ONE structured re-ask, then the caller's fallback.
*
* An off-enum answer is a PARSE FAILURE, never a pick — this function can
* never return an id it was not given (the `allowed` list plus `fallback`).
* Callers put their own "none" sentinel INTO `allowed` (and as `fallback`)
* so the model can decline explicitly; the sentinel is a first-class enum
* member on the wire, exactly as `llmClassifier` has always sent it.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseEnumLine = exports.constrainedEnumPick = void 0;
const forcedToolChoice_js_1 = require("../provider-contract/forcedToolChoice.js");
const linearText_js_1 = require("../lib/linearText.js");
const abort_js_1 = require("../lib/abort.js");
/** Run the pick. Resolves to a member of `allowed`, else `fallback`. */
async function constrainedEnumPick(req) {
return (0, forcedToolChoice_js_1.forcedToolChoiceFor)(req.provider, req.model ?? '')
? pickByForcedTool(req)
: pickByParse(req);
}
exports.constrainedEnumPick = constrainedEnumPick;
/** The forced-tool path: the provider constrains generation to the enum. */
async function pickByForcedTool(req) {
const tool = {
name: req.pickTool.name,
description: req.pickTool.description,
inputSchema: {
type: 'object',
properties: {
[req.pickTool.argName]: {
type: 'string',
enum: [...req.allowed],
description: req.pickTool.argDescription,
},
},
required: [req.pickTool.argName],
additionalProperties: false,
},
};
const resp = await complete(req.provider, {
systemPrompt: req.systemPrompt,
messages: req.messages,
tools: [tool],
toolChoice: { type: 'tool', name: req.pickTool.name },
...(req.model !== undefined && { model: req.model }),
...(req.signal !== undefined && { signal: req.signal }),
});
const call = resp.toolCalls.find((tc) => tc.name === req.pickTool.name);
const picked = call?.args?.[req.pickTool.argName];
// Off-enum = parse failure, never a pick.
return typeof picked === 'string' && req.allowed.includes(picked) ? picked : req.fallback;
}
/** The parse path: strict single line, one structured re-ask, then fallback. */
async function pickByParse(req) {
const instruction = `Answer with EXACTLY one id from this list and nothing else: ${req.allowed.join(', ')}`;
const first = await complete(req.provider, {
systemPrompt: `${req.systemPrompt}\n\n${instruction}`,
messages: req.messages,
...(req.model !== undefined && { model: req.model }),
...(req.signal !== undefined && { signal: req.signal }),
});
const parsed = parseEnumLine(first.content, req.allowed);
if (parsed !== undefined)
return parsed;
const retry = await complete(req.provider, {
systemPrompt: `${req.systemPrompt}\n\n${instruction}`,
messages: [
...req.messages,
{ role: 'assistant', content: first.content },
{
role: 'user',
content: `That answer was not one of the allowed ids. Reply with exactly one of: ` +
`${req.allowed.join(', ')} — a bare id, no punctuation, no explanation.`,
},
],
...(req.model !== undefined && { model: req.model }),
...(req.signal !== undefined && { signal: req.signal }),
});
return parseEnumLine(retry.content, req.allowed) ?? req.fallback;
}
/** Quotes a model opens a bare id with. */
const OPENING_QUOTE = (0, linearText_js_1.anyOf)('"\'`');
/** Quotes and punctuation a model closes a bare id with. */
const CLOSING_DECORATION = (0, linearText_js_1.anyOf)('"\'`.,');
/**
* A trimmed single-token answer that IS one of the allowed ids, else undefined.
* The decoration is walked off each end, not matched with `/[…]+$/`: the
* reply is model output, and that regex retries from every position of a long
* run of quotes (`lib/linearText.ts` · "Why these exist").
*/
function parseEnumLine(content, allowed) {
const line = (0, linearText_js_1.trimTrailing)((0, linearText_js_1.trimLeading)(content.trim(), OPENING_QUOTE), CLOSING_DECORATION);
return allowed.includes(line) ? line : undefined;
}
exports.parseEnumLine = parseEnumLine;
/** One `complete()` with only the fields this machinery uses. */
async function complete(provider, req) {
const request = {
systemPrompt: req.systemPrompt,
messages: req.messages.map((m) => ({ role: m.role, content: m.content })),
...(req.tools !== undefined && { tools: req.tools }),
...(req.toolChoice !== undefined && { toolChoice: req.toolChoice }),
model: req.model ?? '',
};
// The signal rides the request and bounds the wait; an answer that lands
// after it is refused, never taken as a pick (lib/abort.ts · callUnder).
return (0, abort_js_1.callUnder)(request, req.signal, (wired) => provider.complete(wired));
}
//# sourceMappingURL=constrainedEnumPick.js.map