agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
83 lines • 3.99 kB
JavaScript
/**
* llmClassifier — an {@link IntentScorer} that asks a model which declared
* intent the new message is (SG-C tier 2). ONE call per turn, off the hot
* loop, in the RouteTurn stage — never inside the ReAct iterations.
*
* **Constrained enum, never free text.** The pick runs through
* {@link constrainedEnumPick} (extracted from this module in 9.19.0 so the
* tier-3 decider shares the same discipline): when the provider declares
* forced-tool support for that model it rides a forced synthetic tool whose single
* argument is `enum: [...candidateIds, 'none']` (the same machinery the
* `'tool-forced'` output strategy uses); on any other provider it is a
* strict single-line parse validated against the enum, ONE structured
* re-ask, then `'none'`. An off-enum answer is a parse failure, not a route
* — this scorer can never emit an id it wasn't given.
*
* Output maps to scores the framework can judge: picked id → 1, others → 0;
* `'none'` → all 0. `floor: 0` (so `'none'` is honestly "unmatched") and
* `categorical: true` (one id or none — a pick is decisive by construction,
* never diluted by the near-tie margin; see `routingPolicy.ts`).
*
* One of the two injection-engine scorer modules that import
* `provider-contract/types` (the other is `constrainedEnumPick`, its machinery) —
* kept out of `entryScorer.ts` so the keyword/embedding paths stay
* adapter-free.
*/
import { constrainedEnumPick } from './constrainedEnumPick.js';
const PICK_TOOL = 'pick_intent';
const NONE = 'none';
/** Build the classifier. See the module header for the enum discipline. */
export function llmClassifier(provider, options = {}) {
const maxExamples = options.maxExamplesPerIntent ?? 5;
return {
name: 'llm-classifier',
floor: 0,
categorical: true,
...(options.window !== undefined && { window: options.window }),
async score(input, candidates, signal) {
if (candidates.length === 0)
return [];
const ids = candidates.map((c) => c.id);
const allowed = [...ids, NONE];
const picked = await constrainedEnumPick({
provider,
...(options.model !== undefined && { model: options.model }),
systemPrompt: systemPromptFor(candidates, maxExamples),
messages: messagesFor(input),
allowed,
fallback: NONE,
pickTool: {
name: PICK_TOOL,
description: `Report which declared intent the user's message expresses ('${NONE}' if none).`,
argName: 'intent',
argDescription: 'The picked intent id.',
},
...(signal !== undefined && { signal }),
});
return ids.map((id) => ({ id, score: picked === id ? 1 : 0 }));
},
};
}
/** The intent catalog as prompt lines — id, sentence, up to N examples. */
function catalogLines(candidates, maxExamples) {
return candidates
.map((c) => {
const examples = c.examples.slice(0, maxExamples);
const exampleClause = examples.length > 0
? `\n examples: ${examples.map((e) => JSON.stringify(e)).join(' | ')}`
: '';
return `- ${c.id}: ${c.intent}${exampleClause}`;
})
.join('\n');
}
function systemPromptFor(candidates, maxExamples) {
return (`You classify ONE user message against a fixed list of declared intents.\n` +
`Intents:\n${catalogLines(candidates, maxExamples)}\n\n` +
`Pick the single intent id that the message decisively expresses. If none of the ` +
`declared intents fits, pick '${NONE}'. Never invent an id.`);
}
function messagesFor(input) {
const turns = (input.recentTurns ?? []).map((t) => ({ role: t.role, content: t.content }));
return [...turns, { role: 'user', content: input.message }];
}
//# sourceMappingURL=llmClassifier.js.map