agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
453 lines • 20.5 kB
JavaScript
/**
* search/declaration — the ONE rule a `Tool.search` declaration is held to.
*
* Pattern: one validator, two doors (the `assertResultCeiling` precedent):
* `defineTool` refuses at definition, and the MCP ingest door
* (`mcp/toolExtras.ts` · `readToolExtras`) asks the same function
* and drops a declaration it refuses. A declaration nobody vetted —
* a remote `_meta` bag — must land on a message, never a TypeError.
* Role: semantics/ layer, pure. Throws `Error` naming the tool, the field
* and the code (`unclassified-argument`, `unknown-argument`,
* `gaps-without-bears`, `fixed-without-said`, `when-omitted-shape`,
* `statement-text-refused`).
* Emits: N/A.
*
* Why so strict: a declaration is untrusted text from the tool author, and the
* composer builds every coverage row from it. An argument nobody classified
* would be ground nobody accounts for; a default that is neither a value nor
* `{ any: true }` would be read as one of them by a guess. Every such shape is
* refused here, at the line the author wrote, not at the first "not found" of
* the first incident.
*/
import { plainLineProblem } from '../../../lib/plainLine.js';
import { formArguments, sugarForms } from '../../../time/periodForm.js';
import { statementTextProblem } from './statementText.js';
/** The closed role set, tied to {@link SearchRole} in both directions. */
export const SEARCH_ROLES = Object.keys({
population: true,
filter: true,
period: true,
source: true,
query: true,
match: true,
cap: true,
page: true,
inert: true,
});
/** The declaration's keys, tied to {@link ToolSearch}. */
const SEARCH_KEYS = Object.keys({
source: true,
subject: true,
fixed: true,
arguments: true,
complete: true,
gaps: true,
limits: true,
ranking: true,
});
const ARGUMENT_KEYS = Object.keys({
name: true,
role: true,
whenOmitted: true,
said: true,
});
const FIXED_KEYS = Object.keys({
name: true,
value: true,
said: true,
});
const GAP_KEYS = Object.keys({
what: true,
why: true,
bears: true,
});
const WORDS_KEYS = Object.keys({
what: true,
said: true,
});
/** The plain facets a gap may bear on; a filter facet is `{ filter, values? }`. */
const PLAIN_FACETS = ['source', 'population', 'period', 'completeness'];
/** Roles a claim may name a VALUE of in words. */
const WORDED_ROLES = ['filter', 'source'];
const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
const isName = (v) => typeof v === 'string' && v.trim() !== '';
/** A finite number, a string or a boolean — what a default or a qualifier may be. */
export function isSearchScalar(v) {
return (typeof v === 'string' || typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v)));
}
/** `{ any: true }` exactly — no other key, no other value. */
export function isAnyValue(v) {
return isPlain(v) && Object.keys(v).length === 1 && v.any === true;
}
/**
* The declaration as it is STORED — a deep copy, frozen, each argument's
* `said` a null-prototype map — so nothing downstream can mutate it and no
* value a call sends can reach an inherited property through it
* (`valueWords.ts`). `defineTool` and the MCP ingest door store this, after
* `assertToolSearch` accepted the declaration.
*/
export function freezeSearch(search) {
const words = (list) => Object.freeze([...list]);
const said = (map) => {
const out = Object.create(null);
for (const [value, list] of Object.entries(map))
out[value] = words(list);
return Object.freeze(out);
};
const bears = (b) => typeof b === 'string'
? b
: Object.freeze({
filter: b.filter,
...(b.values !== undefined && { values: Object.freeze([...b.values]) }),
});
return Object.freeze({
source: Object.freeze({ what: search.source.what, said: words(search.source.said) }),
subject: Object.freeze({ what: search.subject.what, said: words(search.subject.said) }),
...(search.fixed !== undefined && {
fixed: Object.freeze(search.fixed.map((q) => Object.freeze({ name: q.name, value: q.value, said: words(q.said) }))),
}),
arguments: Object.freeze(search.arguments.map((a) => Object.freeze({
name: a.name,
role: a.role,
...(a.whenOmitted !== undefined && {
whenOmitted: Array.isArray(a.whenOmitted)
? Object.freeze([...a.whenOmitted])
: isAnyValue(a.whenOmitted)
? Object.freeze({ any: true })
: a.whenOmitted,
}),
...(a.said !== undefined && { said: said(a.said) }),
}))),
...(search.complete !== undefined && { complete: search.complete }),
...(search.ranking !== undefined && { ranking: search.ranking }),
...(search.gaps !== undefined && {
gaps: Object.freeze(search.gaps.map((g) => Object.freeze({ what: g.what, why: g.why, bears: bears(g.bears) }))),
}),
...(search.limits !== undefined && {
limits: Object.freeze({
...(search.limits.cap !== undefined && { cap: search.limits.cap }),
...(search.limits.byCredential === true && { byCredential: true }),
}),
}),
});
}
/**
* The arguments a tool's `period` names — its single argument, its zone
* argument and every argument of its forms — or `undefined` when it declares
* no period. What a `period`-role search argument must be one of.
*/
export function periodArgumentsOf(period) {
if (period === undefined)
return undefined;
const named = new Set();
if (period.argument !== undefined)
named.add(period.argument);
if (period.zoneArgument !== undefined)
named.add(period.zoneArgument);
for (const form of sugarForms(period)) {
for (const a of formArguments(form))
named.add(a.argument);
}
return [...named];
}
/**
* Refuse a `search` this library cannot honour, at definition time — naming
* the tool, the field and the code. `undefined` declares nothing and passes.
*
* @example
* ```ts
* assertToolSearch('list_incidents', search, { inputSchema });
* ```
*/
export function assertToolSearch(toolName, search, context) {
if (search === undefined)
return;
const refuse = (field, message, code) => {
throw new Error(`defineTool('${toolName}'): \`${field}\` ${message}` +
(code === undefined ? '' : ` (${code})`));
};
if (!isPlain(search)) {
refuse('search', 'must be { source, subject, arguments, fixed?, complete?, gaps?, limits? } — what one ' +
'call of this tool searches. To declare nothing, omit the field.');
}
refuseUnknown(search, SEARCH_KEYS, 'search', refuse);
checkWords(search.source, 'search.source', refuse);
checkWords(search.subject, 'search.subject', refuse);
const properties = propertiesOf(context.inputSchema);
const fixedNames = checkFixed(search.fixed, properties, refuse);
const roles = checkArguments(search.arguments, properties, context, refuse);
if (search.complete !== undefined && search.complete !== 'within-arguments') {
refuse('search.complete', `is ${JSON.stringify(search.complete)} — the one statement is 'within-arguments' ` +
'(complete for the population, filters and period in effect). Omit it and no call is ' +
'ever complete.');
}
if (search.ranking !== undefined && search.ranking !== 'approximate') {
refuse('search.ranking', `is ${JSON.stringify(search.ranking)} — the one value is 'approximate' (a vector, ` +
'top-k or fuzzy ranking). Omit it for a search that returns every row that fits.');
}
if (search.ranking === 'approximate' && search.complete !== undefined) {
refuse('search.complete', "is declared beside ranking: 'approximate' — a ranked search returns the rows nearest, " +
'never every row that fits, so it is never complete. Drop `complete`.', 'complete-beside-approximate');
}
checkGaps(search.gaps, roles, fixedNames, refuse);
checkLimits(search.limits, refuse);
// Every text that can reach a statement, judged by ONE rule, as the text
// that lands there (a fixed qualifier as `name: value`) — no per-field drift.
for (const [field, text] of statementTextsOf(search))
checkStatementText(text, field, refuse);
}
/**
* EVERY declaration text that can reach a statement the library writes, as it
* lands there: the source's and the subject's `what`; each fixed qualifier and
* each declared default as `name: value` (one per value of a list); each
* classified argument's name (a sent value is the call's, quoted by the
* writer, never declaration text); each gap's `what` and `why`. A new field
* that reaches a statement joins THIS list, so it meets the one rule.
* Assumes a declaration whose shape `assertToolSearch` already accepted.
*/
export function statementTextsOf(search) {
const out = [
['search.source.what', search.source.what],
['search.subject.what', search.subject.what],
];
(search.fixed ?? []).forEach((q, i) => {
out.push([`search.fixed[${i}]`, `${q.name}: ${String(q.value)}`]);
});
search.arguments.forEach((a, i) => {
if (a.role === 'inert')
return;
const at = `search.arguments[${i}]`;
const omitted = a.whenOmitted;
if (omitted === undefined || isAnyValue(omitted)) {
out.push([at, isAnyValue(omitted) ? `${a.name}: any` : a.name]);
return;
}
const values = Array.isArray(omitted)
? omitted
: [omitted];
for (const v of values)
out.push([`${at}.whenOmitted`, `${a.name}: ${String(v)}`]);
});
(search.gaps ?? []).forEach((g, i) => {
out.push([`search.gaps[${i}].what`, g.what]);
out.push([`search.gaps[${i}].why`, g.why]);
});
return out;
}
function refuseUnknown(value, known, field, refuse) {
const unknown = Object.keys(value).find((key) => !known.includes(key));
if (unknown !== undefined) {
refuse(field, `has an unknown key '${unknown}' — the keys are ${known.join(', ')}.`);
}
}
/** A non-empty string — its shape is the statement rule's (`statementTextsOf`). */
function checkPresent(value, field, refuse) {
if (!isName(value))
return refuse(field, 'must be a non-empty string.');
return value;
}
/** One plain line, non-empty. */
function checkLine(value, field, refuse) {
if (!isName(value))
return refuse(field, 'must be a non-empty string.');
const problem = plainLineProblem(`\`${field}\``, value);
if (problem !== undefined)
refuse(field, `is not one plain line: ${problem}`);
return value;
}
/**
* Text that can reach a statement the library writes — one plain line, at
* most 120 characters, no instruction (`statementText.ts`).
*/
function checkStatementText(value, field, refuse) {
if (!isName(value))
return refuse(field, 'must be a non-empty string.');
const problem = statementTextProblem(field, value);
if (problem !== undefined)
refuse(field, `is refused: ${problem}`, 'statement-text-refused');
return value;
}
/** A non-empty list of plain one-line words. */
function checkSaid(value, field, refuse, code) {
if (!Array.isArray(value) || value.length === 0) {
refuse(field, 'must list at least one word a claim may use — the said words are the ONLY vocabulary ' +
'a claim is matched to, so an empty list matches nothing.', code);
}
value.forEach((word, i) => checkLine(word, `${field}[${i}]`, refuse));
}
function checkWords(value, field, refuse) {
if (!isPlain(value))
refuse(field, "must be { what, said } — e.g. { what: 'the incident store', said: ['the incident store'] }.");
refuseUnknown(value, WORDS_KEYS, field, refuse);
checkPresent(value.what, `${field}.what`, refuse);
checkSaid(value.said, `${field}.said`, refuse);
}
function propertiesOf(schema) {
const properties = schema.properties;
return isPlain(properties) ? Object.keys(properties) : [];
}
function checkFixed(fixed, properties, refuse) {
const names = new Set();
if (fixed === undefined)
return names;
if (!Array.isArray(fixed)) {
return refuse('search.fixed', 'must be a list of { name, value, said } qualifiers.');
}
fixed.forEach((raw, i) => {
const at = `search.fixed[${i}]`;
if (!isPlain(raw))
refuse(at, 'must be { name, value, said }.');
const q = raw;
refuseUnknown(q, FIXED_KEYS, at, refuse);
const name = checkLine(q.name, `${at}.name`, refuse);
if (properties.includes(name)) {
refuse(`${at}.name`, `'${name}' is an input property — a fixed qualifier is one the tool ALWAYS applies, ` +
'so it is never sent. Classify the property in `arguments` instead.');
}
if (names.has(name))
refuse(`${at}.name`, `'${name}' is declared twice.`);
names.add(name);
if (!isSearchScalar(q.value)) {
refuse(`${at}.value`, 'must be a string, a finite number or a boolean.');
}
checkSaid(q.said, `${at}.said`, refuse, 'fixed-without-said');
});
return names;
}
function checkWhenOmitted(arg, at, refuse) {
if (!('whenOmitted' in arg) || arg.whenOmitted === undefined)
return;
if (arg.role === 'inert') {
refuse(`${at}.whenOmitted`, 'is declared on an inert argument — an inert argument changes the bytes, not the ' +
'ground, so a default means nothing there. Drop it.');
}
const v = arg.whenOmitted;
const ok = isSearchScalar(v) ||
isAnyValue(v) ||
(Array.isArray(v) && v.length > 0 && v.every(isSearchScalar));
if (!ok) {
refuse(`${at}.whenOmitted`, `is ${JSON.stringify(v)} — it has two forms and no third: the default VALUE execute ` +
'applies (a string, a finite number, a boolean or a list of them), composed as if ' +
'sent; or { any: true }, omission narrows nothing. Omit it and an unsent argument ' +
'is a gap.', 'when-omitted-shape');
}
}
function checkArgumentSaid(arg, at, refuse) {
if (arg.said === undefined)
return;
if (!WORDED_ROLES.includes(arg.role)) {
refuse(`${at}.said`, `is declared on a '${String(arg.role)}' argument — value words belong to 'filter' and ` +
"'source' arguments only (a population value's words come from the result's `resolved`).");
}
if (!isPlain(arg.said) || Object.keys(arg.said).length === 0) {
refuse(`${at}.said`, "must map each value to its words — { critical: ['critical', 'sev1'] }.");
}
for (const [value, words] of Object.entries(arg.said)) {
checkSaid(words, `${at}.said.${value}`, refuse);
}
}
function checkArguments(args, properties, context, refuse) {
if (!Array.isArray(args)) {
return refuse('search.arguments', 'must classify EVERY input property — a list of { name, role } ([] for a tool with none).', 'unclassified-argument');
}
const roles = new Map();
args.forEach((raw, i) => {
const at = `search.arguments[${i}]`;
if (!isPlain(raw))
refuse(at, 'must be { name, role, whenOmitted?, said? }.');
const arg = raw;
refuseUnknown(arg, ARGUMENT_KEYS, at, refuse);
const name = checkLine(arg.name, `${at}.name`, refuse);
if (!properties.includes(name)) {
refuse(`${at}.name`, `'${name}' is not a property of inputSchema — a search declares only arguments a call ` +
'can send.', 'unknown-argument');
}
if (roles.has(name))
refuse(`${at}.name`, `'${name}' is classified twice.`);
if (!SEARCH_ROLES.includes(arg.role)) {
refuse(`${at}.role`, `must be one of ${SEARCH_ROLES.join(', ')}.`);
}
roles.set(name, arg.role);
checkWhenOmitted(arg, at, refuse);
checkArgumentSaid(arg, at, refuse);
if (arg.role === 'period' && context.periodArguments !== undefined) {
if (!context.periodArguments.includes(name)) {
refuse(`${at}.role`, `is 'period' for '${name}', which the tool's \`period\` does not name ` +
`(${context.periodArguments.join(', ') || 'none'}). The period argument is the one ` +
'`period` declares.');
}
}
});
const unclassified = properties.find((p) => !roles.has(p));
if (unclassified !== undefined) {
refuse('search.arguments', `does not classify the input property '${unclassified}' — every property needs a role ` +
`(${SEARCH_ROLES.join(', ')}). Ground nobody classified is ground nobody accounts for; ` +
"a property that changes no ground is 'inert'.", 'unclassified-argument');
}
if ([...roles.values()].filter((r) => r === 'source').length > 1) {
refuse('search.arguments', 'classifies more than one `source` argument — a call picks one store.');
}
return roles;
}
function checkBears(bears, at, roles, fixedNames, refuse) {
if (typeof bears === 'string' && PLAIN_FACETS.includes(bears))
return bears;
if (isPlain(bears)) {
refuseUnknown(bears, ['filter', 'values'], at, refuse);
const filter = checkLine(bears.filter, `${at}.filter`, refuse);
if (roles.get(filter) !== 'filter' && !fixedNames.has(filter)) {
refuse(`${at}.filter`, `'${filter}' is neither a 'filter' argument nor a fixed qualifier of this search.`);
}
const values = bears.values;
if (values !== undefined &&
(!Array.isArray(values) || values.length === 0 || !values.every(isSearchScalar))) {
refuse(`${at}.values`, 'must list the values the gap concerns — omit it for every value.');
}
return bears;
}
return refuse(at, `is ${JSON.stringify(bears)} — a gap bears on 'source', 'population', 'period', ` +
"'completeness' or { filter: '<name>', values?: [...] }.", 'gaps-without-bears');
}
function checkGaps(gaps, roles, fixedNames, refuse) {
if (gaps === undefined)
return;
if (!Array.isArray(gaps)) {
refuse('search.gaps', 'must be a list of { what, why, bears } — or omit it.');
}
gaps.forEach((raw, i) => {
const at = `search.gaps[${i}]`;
if (!isPlain(raw))
refuse(at, 'must be { what, why, bears }.');
const gap = raw;
refuseUnknown(gap, GAP_KEYS, at, refuse);
checkPresent(gap.what, `${at}.what`, refuse);
if (!isName(gap.why) || gap.bears === undefined) {
refuse(at, 'needs both `why` and `bears` — a blind spot with no reason cannot be acted on, and one ' +
'with no facet cannot be weighed against a claim.', 'gaps-without-bears');
}
checkBears(gap.bears, `${at}.bears`, roles, fixedNames, refuse);
});
}
function checkLimits(limits, refuse) {
if (limits === undefined)
return;
const shape = "must be { cap?: <n> | 'unknown', byCredential?: true } — at least one.";
if (!isPlain(limits))
refuse('search.limits', shape);
refuseUnknown(limits, ['cap', 'byCredential'], 'search.limits', refuse);
const { cap, byCredential } = limits;
if (cap === undefined && byCredential === undefined)
refuse('search.limits', shape);
if (byCredential !== undefined && byCredential !== true) {
refuse('search.limits.byCredential', `is ${JSON.stringify(byCredential)} — say true when the read sees only what the ` +
"caller's credential may see, or omit it.");
}
if (cap === undefined)
return;
const ok = cap === 'unknown' || (typeof cap === 'number' && Number.isInteger(cap) && cap > 0);
if (!ok) {
refuse('search.limits.cap', `is ${JSON.stringify(cap)} — a server cap is a positive whole number of rows, or ` +
"'unknown' when the tool cannot say (an unknown cap bears on completeness every call).");
}
}
//# sourceMappingURL=declaration.js.map