agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
185 lines • 10.4 kB
JavaScript
/** Negative-claim arms: app configuration and the typed model citation schema.
* This module never calls a parser or decides whether a search establishes a claim.
* Delivery adds its own statement after parsing; that enriched field is not model input. */
import { copyJson, ownData } from '../../../lib/boundedJson.js';
import { MAX_NEGATIVE_CLAIMS } from '../../../integrity/negative-claim/limits.js';
import { MAX_CITED_READS } from '../../../integrity/negative-claim/citation.js';
import { resolveNegativeClaimPhrases, } from '../../../integrity/negative-claim/phrases.js';
export const NEGATIVE_HANDLE_PATTERN = '^r[1-9][0-9]{0,3}$';
const KINDS = ['none', 'not-found', 'complete'];
const FIELDS = ['kind', 'about', 'restsOn'];
const fail = (message) => {
throw new Error(`Agent.negativeClaims: ${message}`);
};
const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
/** Validate once, without keeping any mutable caller-owned arrays. */
export function resolveNegativeClaims(options) {
let source;
try {
source = ownData(options);
}
catch {
return fail('options must be a plain data object.');
}
if (['strictProse', 'enforceProse'].some((key) => Object.hasOwn(source, key))) {
return fail('strictProse and enforceProse options are not available; prose is observed, never enforced.');
}
if (Object.keys(source).some((key) => !['field', 'tools', 'unestablished', 'prose', 'phrases'].includes(key))) {
return fail('options accept only field, tools, unestablished, prose and phrases.');
}
const phrases = source.phrases === undefined
? undefined
: resolveNegativeClaimPhrases(source.phrases);
if (Object.hasOwn(source, 'prose')) {
if (source.prose !== true ||
['field', 'tools', 'unestablished'].some((key) => Object.hasOwn(source, key)))
return fail('prose must be true and cannot be combined with field, tools or unestablished.');
return Object.freeze({ mode: 'prose', ...(phrases !== undefined && { phrases }) });
}
const field = source.field;
if (typeof field !== 'string' ||
!/^[A-Za-z_][A-Za-z0-9_-]{0,127}$/.test(field) ||
field === '_findings') {
return fail('field must name one top-level answer property (letters, digits, underscore or hyphen), not _findings.');
}
const tools = source.tools;
if (!Array.isArray(tools) ||
tools.length === 0 ||
tools.length > 256 ||
tools.some((name) => typeof name !== 'string' || name.trim().length === 0 || name.length > 256)) {
return fail('tools must be a non-empty list of at most 256 registered tool names.');
}
if (new Set(tools).size !== tools.length)
return fail('tools must not repeat a name.');
const unestablished = source.unestablished ?? 'withhold';
if (unestablished !== 'withhold' && unestablished !== 'state-searched') {
return fail("unestablished must be 'withhold' or 'state-searched'.");
}
return Object.freeze({
mode: 'typed',
field,
tools: Object.freeze([...tools]),
unestablished,
...(phrases !== undefined && { phrases }),
});
}
/** Provider-only and MCP tools cannot become trusted by being named in this list. */
export function assertNegativeClaimsTools(config, tools) {
for (const name of config.tools) {
const tool = tools.find((candidate) => candidate.schema.name === name);
if (tool === undefined)
return fail(`tool '${name}' is not registered locally; register it with .tool() or a skill.`);
if (tool.source !== undefined)
return fail(`tool '${name}' is remote; only local tools may supply typed negative evidence.`);
// `among` is returned by execute, not declared on Tool. A local tool with
// no search therefore stays eligible, but establishes nothing unless an
// admitted among result supplies its coverage at the runtime boundary.
}
}
function schemaObject(value, at) {
return record(value) ? value : fail(`${at} must be declared directly in the output JSON Schema.`);
}
/** Build the model-facing copy. A parse function is never executed to infer a schema.
* Root/field/item references and unions are deliberately not guessed: expose this
* one citation field directly while the rest of the app's schema is preserved. */
export function negativeClaimsSchema(config, schema) {
if (schema === undefined)
return fail('requires outputSchema(..., { jsonSchema }) or a parser with toJsonSchema(); the library cannot inspect a parse function.');
let root;
try {
root = copyJson(schema, 1024 * 1024, false);
}
catch {
return fail('the output JSON Schema must be bounded plain JSON data.');
}
if (root.type !== 'object')
return fail('the output JSON Schema must declare an object.');
const properties = schemaObject(root.properties, 'output properties');
if (!Object.hasOwn(properties, config.field))
return fail(`field '${config.field}' is not declared by the output JSON Schema.`);
if (!Array.isArray(root.required) || !root.required.includes(config.field)) {
return fail(`field '${config.field}' must be required; use an empty array when there is no typed claim.`);
}
const field = schemaObject(properties[config.field], `field '${config.field}'`);
if (field.type !== 'array')
return fail(`field '${config.field}' must be an array of citations.`);
const item = schemaObject(field.items, `items of '${config.field}'`);
if (item.type !== 'object')
return fail(`items of '${config.field}' must be citation objects.`);
const fields = schemaObject(item.properties, `properties of '${config.field}' items`);
if (Object.keys(fields).some((key) => !FIELDS.includes(key))) {
return fail(`'${config.field}' entries declare only kind, about and restsOn; statement, delivery and version are library-owned output fields.`);
}
const required = item.required;
if (!Array.isArray(required) ||
!required.includes('kind') ||
!required.includes('restsOn') ||
required.some((key) => typeof key !== 'string' || !Object.hasOwn(fields, key))) {
return fail(`'${config.field}' entries must require kind and restsOn and declare each required property.`);
}
const kind = schemaObject(fields.kind, 'kind');
if (kind.type !== 'string' ||
(kind.enum !== undefined &&
(!Array.isArray(kind.enum) ||
kind.enum.length === 0 ||
kind.enum.some((value) => !KINDS.includes(value))))) {
return fail("kind must be a string restricted to 'none', 'not-found' or 'complete'.");
}
const about = fields.about === undefined ? undefined : schemaObject(fields.about, 'about');
const aboutTypes = about === undefined ? [] : Array.isArray(about.type) ? about.type : [about.type];
if (about !== undefined &&
(aboutTypes.length === 0 ||
aboutTypes.some((type) => !['string', 'number', 'integer'].includes(type)))) {
return fail('about must declare a string or number, never null.');
}
const restsOn = schemaObject(fields.restsOn, 'restsOn');
const handle = schemaObject(restsOn.items, 'restsOn.items');
if (restsOn.type !== 'array' || handle.type !== 'string')
return fail('restsOn must be an array of string handles.');
if (handle.pattern !== undefined && handle.pattern !== NEGATIVE_HANDLE_PATTERN) {
return fail(`restsOn handles must use pattern '${NEGATIVE_HANDLE_PATTERN}'.`);
}
const cap = (node, maximum, at) => {
for (const key of ['minItems', 'maxItems']) {
if (node[key] !== undefined &&
(typeof node[key] !== 'number' ||
!Number.isSafeInteger(node[key]) ||
node[key] < 0)) {
return fail(`${at}.${key} must be a non-negative integer.`);
}
}
if (typeof node.minItems === 'number' && node.minItems > maximum)
return fail(`${at}.minItems exceeds the checker limit ${maximum}.`);
node.maxItems = typeof node.maxItems === 'number' ? Math.min(node.maxItems, maximum) : maximum;
if (typeof node.minItems === 'number' && node.minItems > node.maxItems)
return fail(`${at} has incompatible item bounds.`);
};
cap(field, MAX_NEGATIVE_CLAIMS, config.field);
cap(restsOn, MAX_CITED_READS, 'restsOn');
restsOn.minItems = Math.max(typeof restsOn.minItems === 'number' ? restsOn.minItems : 0, 1);
if (restsOn.maxItems < restsOn.minItems)
return fail('restsOn must permit at least one handle.');
handle.pattern = NEGATIVE_HANDLE_PATTERN;
kind.enum ??= [...KINDS];
if (about !== undefined && aboutTypes.includes('string')) {
about.minLength = Math.max(typeof about.minLength === 'number' ? about.minLength : 0, 1);
about.maxLength = Math.min(typeof about.maxLength === 'number' ? about.maxLength : 256, 256);
}
item.additionalProperties = false;
return copyJson(root, 1024 * 1024, true);
}
/** One always-on instruction; handles themselves are served beside admitted results. */
export function negativeClaimsInstruction(config, schema) {
const fieldSchema = schema.properties[config.field];
return (`The required ${JSON.stringify(config.field)} field contains typed negative citations. ` +
`Use only result handles served by these tools: ${JSON.stringify(config.tools)}. ` +
'Each entry requires kind and restsOn handles. Choose none or not-found only for an absence; ' +
'choose complete only to claim the returned list covers the whole declared search. ' +
'The library verifies that claim and only normalizes none versus not-found over declared absence. ' +
'If about is present, it must include every recorded population value for the cited reads. ' +
'Do not write statement text: the library supplies the actual searched subject and scope. ' +
'State negative claims only through this citation field; do not repeat them in summaries or other prose. ' +
'The library writes the delivered statement. Use [] when making no typed negative claim. ' +
`Field JSON Schema: ${JSON.stringify(fieldSchema)}`);
}
//# sourceMappingURL=config.js.map