UNPKG

agentfootprint

Version:

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

116 lines • 5.55 kB
/** * 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. */ import { forcedToolChoiceFor } from '../provider-contract/forcedToolChoice.js'; import { anyOf, trimLeading, trimTrailing } from '../lib/linearText.js'; import { callUnder } from '../lib/abort.js'; /** Run the pick. Resolves to a member of `allowed`, else `fallback`. */ export async function constrainedEnumPick(req) { return forcedToolChoiceFor(req.provider, req.model ?? '') ? pickByForcedTool(req) : pickByParse(req); } /** 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 = anyOf('"\'`'); /** Quotes and punctuation a model closes a bare id with. */ const CLOSING_DECORATION = 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"). */ export function parseEnumLine(content, allowed) { const line = trimTrailing(trimLeading(content.trim(), OPENING_QUOTE), CLOSING_DECORATION); return allowed.includes(line) ? line : undefined; } /** 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 callUnder(request, req.signal, (wired) => provider.complete(wired)); } //# sourceMappingURL=constrainedEnumPick.js.map