agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
371 lines • 17.1 kB
JavaScript
/**
* toolArgsValidation — validate LLM-produced tool args against the tool's
* declared `inputSchema` BEFORE dispatch (backlog #9).
*
* Pattern: pure function module — no events, and no state beyond one compiled-
* regex cache (which doubles as the warn-once ledger for `pattern`s
* that do not compile); each dispatch boundary owns when to call it
* and what to do with the verdict.
* Role: The model writes tool args as free-form JSON; nothing guaranteed
* they match the schema the tool advertised. Dispatching garbage
* surfaced as deep tool stack traces (or worse, silent misbehavior).
* Validating at the boundary turns a malformed call into a
* MODEL-VISIBLE structured tool result, so the LLM corrects its
* args and retries on the next ReAct iteration.
*
* ── Honest-subset contract ────────────────────────────────────────────────
* This is NOT a full JSON Schema implementation. It enforces the core that
* tool schemas in the wild actually use, and IGNORES everything else
* (permissive on unknown keywords — a schema using `oneOf`/`$ref` still
* validates the supported core, never false-rejects on the rest):
*
* ENFORCED: `type` (object/array/string/number/integer/boolean/null,
* union arrays), `required`, `properties` (recursive),
* `items` (single-schema, recursive), `enum` (primitives),
* `additionalProperties: false` when declared names are knowable,
* and the STRING SHAPE keywords `pattern` / `minLength` /
* `maxLength`. Object membership means OWN properties, not
* prototype lookup. An omitted `properties` map declares no names.
* IGNORED: format, numeric min/max, oneOf/anyOf/allOf/not, $ref,
* const, dependencies, patternProperties, …
* A malformed `properties` map or nonempty/malformed `patternProperties`
* defers extra-key rejection: unknown name coverage cannot prove a key extra.
* Independent required and declared-property checks still apply.
*
* ── Why string SHAPE joined the subset ────────────────────────────────────
* A tool result ended with an offer ("I can also map these ids to volume
* names"); the person answered "yes please"; the model bound that sentence as
* the IDENTIFIER argument and dispatched. The tool's schema declared the
* identifier's shape and this boundary did not read it, so a call that could
* never succeed cost a round trip — and the consumer hand-rolled an
* affirmative blocklist to do what the declaration already said. A declared
* shape a boundary ignores is worse than no declaration: the author believes
* it is enforced.
*
* `pattern` is JSON Schema's own semantics — UNANCHORED, ECMA-262, applied to
* strings only. A regex the schema author got wrong must never take dispatch
* with it: it is compiled once, and a compile failure degrades to no-pattern
* plus one developer warning.
*
* ── Security: what an issue may echo ──────────────────────────────────────
* `type` / `enum` / `required` / `additionalProperties` issues name the PATH,
* the EXPECTED shape, and the TYPE of what arrived — never the supplied value.
* Enum expectations echo SCHEMA values only (already LLM-visible in the tools
* block).
*
* String-SHAPE issues are the one exception, and it is a narrow one: the
* complaint IS the value's shape, so `expected string, got string` teaches
* nothing and the correction cannot converge. They carry `value` — a capped
* excerpt ({@link MAX_VALUE_CHARS}) of the offending string — plus `hint`, the
* parameter's own `description`, which is where an author writes what the
* identifier looks like. Neither adds exposure: the value is the model's own
* tool-call argument, already verbatim in the history this message is appended
* to. The OTel adapter renders `path`/`expected`/`got` only, so third-party
* telemetry stays value-free either way.
*/
/** Finite, value-free transport failure through the existing argument refusal owner. */
export function streamedToolArgIssues(failure) {
return [
{
path: '',
expected: 'a complete JSON object from the tool argument stream',
got: failure.reason,
},
];
}
/** Cap so a pathological schema/args pair can't flood history or events. */
const MAX_ISSUES = 10;
/** Ceiling on the echoed excerpt of an offending string. Enough to recognize
* a sentence bound where an identifier belonged; short enough that a 5 MB
* argument cannot ride the message into history. */
const MAX_VALUE_CHARS = 80;
/**
* Ceiling on compiled `pattern`s held. Schemas are finite in a process, so
* this is a fence against dynamically-minted ones rather than a tuning knob:
* past it, patterns compile per call (and a broken one warns again) instead of
* the map growing without bound.
*/
const MAX_CACHED_PATTERNS = 256;
/**
* Compiled `pattern`s, and the warn-once ledger for the broken ones. A `null`
* entry means "this pattern does not compile" — recorded so a schema bug is
* reported once to the developer rather than once per dispatch.
*/
const patternCache = new Map();
function compilePattern(pattern) {
const cached = patternCache.get(pattern);
if (cached !== undefined)
return cached;
let compiled = null;
try {
// No flags: JSON Schema `pattern` is an unanchored ECMA-262 match, and a
// `g` flag would make the RegExp stateful across calls.
compiled = new RegExp(pattern);
}
catch {
console.warn(`[agentfootprint] a tool inputSchema declares a \`pattern\` that is not a valid regular ` +
`expression (${pattern}) — it is IGNORED, so this argument's shape is NOT enforced. ` +
`Fix the expression or drop the keyword.`);
}
if (patternCache.size < MAX_CACHED_PATTERNS)
patternCache.set(pattern, compiled);
return compiled;
}
/** A length bound this validator will act on: a non-negative integer. Anything
* else is outside the honest subset and ignored rather than guessed at. */
function lengthBound(raw) {
return typeof raw === 'number' && Number.isInteger(raw) && raw >= 0 ? raw : undefined;
}
function excerptOf(value) {
return value.length <= MAX_VALUE_CHARS ? value : `${value.slice(0, MAX_VALUE_CHARS)}…`;
}
function descriptionOf(schema) {
const raw = schema.description;
if (typeof raw !== 'string')
return undefined;
const trimmed = raw.trim();
return trimmed === '' ? undefined : trimmed;
}
/**
* The string SHAPE keywords. At most ONE issue per argument: `pattern`
* describes the value completely, so adding "and it is too short" is a second
* sentence about one mistake.
*/
function checkStringShape(value, schema, path, issues) {
const hint = descriptionOf(schema);
const teach = (expected, got) => {
issues.push({
path,
expected,
got,
value: excerptOf(value),
...(hint !== undefined && { hint }),
});
};
const pattern = schema.pattern;
if (typeof pattern === 'string') {
const regex = compilePattern(pattern);
if (regex !== null && !regex.test(value)) {
teach(`a string matching ${pattern}`, 'string');
return;
}
}
const min = lengthBound(schema.minLength);
if (min !== undefined && value.length < min) {
teach(`a string of at least ${min} characters`, `string of length ${value.length}`);
return;
}
const max = lengthBound(schema.maxLength);
if (max !== undefined && value.length > max) {
teach(`a string of at most ${max} characters`, `string of length ${value.length}`);
}
}
function typeNameOf(value) {
if (value === null)
return 'null';
if (Array.isArray(value))
return 'array';
return typeof value;
}
/** Does `value` satisfy one JSON-Schema `type` keyword entry? */
function matchesType(value, schemaType) {
switch (schemaType) {
case 'object':
return typeof value === 'object' && value !== null && !Array.isArray(value);
case 'array':
return Array.isArray(value);
case 'integer':
return typeof value === 'number' && Number.isInteger(value);
case 'number':
return typeof value === 'number';
case 'string':
return typeof value === 'string';
case 'boolean':
return typeof value === 'boolean';
case 'null':
return value === null;
default:
// Unknown type keyword → permissive (honest-subset contract).
return true;
}
}
function isPrimitive(value) {
return (value === null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean');
}
/**
* Closing an object requires knowing which names its declarations cover.
* Missing `properties` means no explicit names; malformed maps do not.
* `patternProperties` is outside this validator's subset, so only its absent
* or empty form proves it contributes no names. Never turn ignored coverage
* into a false refusal, nor implement a second pattern-schema engine here.
*/
function canCheckExtraKeys(schema, properties) {
if (schema.properties !== undefined && properties === undefined)
return false;
const patterns = schema.patternProperties;
return (patterns === undefined ||
(typeof patterns === 'object' &&
patterns !== null &&
!Array.isArray(patterns) &&
Object.keys(patterns).length === 0));
}
function validateNode(value, schema, path, issues) {
if (issues.length >= MAX_ISSUES)
return;
// `type` — string or union array. Unknown keywords pass.
const schemaType = schema.type;
if (typeof schemaType === 'string' || Array.isArray(schemaType)) {
const candidates = Array.isArray(schemaType)
? schemaType.filter((t) => typeof t === 'string')
: [schemaType];
if (candidates.length > 0 && !candidates.some((t) => matchesType(value, t))) {
issues.push({ path, expected: candidates.join(' | '), got: typeNameOf(value) });
return; // type is wrong — deeper checks would only cascade noise
}
}
// `enum` — primitives only (object members are out of subset → ignored).
const enumValues = schema.enum;
if (Array.isArray(enumValues) && enumValues.length > 0 && enumValues.every(isPrimitive)) {
if (!enumValues.some((candidate) => candidate === value)) {
issues.push({
path,
expected: `one of ${enumValues.map((candidate) => JSON.stringify(candidate)).join(', ')}`,
got: typeNameOf(value),
});
return;
}
}
// String SHAPE — same place, same dial, same result shape as `type`/`enum`
// above. Applies to strings only, which is JSON Schema's own rule.
if (typeof value === 'string') {
checkStringShape(value, schema, path, issues);
return;
}
// Object keywords.
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
const record = value;
const properties = typeof schema.properties === 'object' &&
schema.properties !== null &&
!Array.isArray(schema.properties)
? schema.properties
: undefined;
const required = schema.required;
if (Array.isArray(required)) {
for (const key of required) {
if (typeof key !== 'string')
continue;
if (!Object.hasOwn(record, key)) {
issues.push({
path: path === '' ? key : `${path}.${key}`,
expected: 'required',
got: 'missing',
});
if (issues.length >= MAX_ISSUES)
return;
}
}
}
if (properties) {
for (const [key, childSchema] of Object.entries(properties)) {
if (!Object.hasOwn(record, key))
continue; // absent optional → fine
if (typeof childSchema !== 'object' || childSchema === null)
continue;
validateNode(record[key], childSchema, path === '' ? key : `${path}.${key}`, issues);
if (issues.length >= MAX_ISSUES)
return;
}
}
// Own enumerable string keys are the JSON boundary. Inherited names are
// neither supplied arguments nor explicit property declarations.
if (schema.additionalProperties === false && canCheckExtraKeys(schema, properties)) {
for (const key of Object.keys(record)) {
if (properties === undefined || !Object.hasOwn(properties, key)) {
issues.push({
path: path === '' ? key : `${path}.${key}`,
expected: 'no additional properties',
got: typeNameOf(record[key]),
});
if (issues.length >= MAX_ISSUES)
return;
}
}
}
}
// Array `items` — single-schema form only (tuple form is out of subset).
if (Array.isArray(value)) {
const items = schema.items;
if (typeof items === 'object' && items !== null && !Array.isArray(items)) {
for (let i = 0; i < value.length; i++) {
validateNode(value[i], items, `${path}[${i}]`, issues);
if (issues.length >= MAX_ISSUES)
return;
}
}
}
}
/**
* Validate tool-call args against the tool's `inputSchema`.
*
* Total function: a malformed/exotic SCHEMA never throws — anything outside
* the honest subset is ignored, so the worst a bad schema can do is
* under-validate (never block a legitimate call).
*/
export function validateToolArgs(args, inputSchema) {
if (!inputSchema || typeof inputSchema !== 'object')
return { ok: true, issues: [] };
const issues = [];
validateNode(args ?? {}, inputSchema, '', issues);
return { ok: issues.length === 0, issues };
}
/**
* Validate ONE value against ONE property's own schema — the node rule
* `validateToolArgs` applies below the root, and nothing above it.
*
* Exists for the inputs layer (`tools/declare.ts` ·
* `assertAskOrAssume`): a declared `assume` value or `ask` choice is judged
* against `inputSchema.properties[argument]` alone. Wrapping it as
* `validateToolArgs({ [argument]: value }, inputSchema)` would enforce the
* ROOT `required` too, and refuse a rule on `window` because the
* declaration does not also carry `service`. Same honest subset, same total
* function: an exotic property schema under-validates, never throws.
*
* @example
* ```ts
* validatePropertyValue('2h', { type: 'string', enum: ['1h', '2h'] }).ok; // true
* validatePropertyValue('9h', { type: 'string', enum: ['1h', '2h'] }).ok; // false
* ```
*/
export function validatePropertyValue(value, propertySchema) {
if (!propertySchema || typeof propertySchema !== 'object')
return { ok: true, issues: [] };
const issues = [];
validateNode(value, propertySchema, '', issues);
return { ok: issues.length === 0, issues };
}
/**
* Render the MODEL-VISIBLE tool result for a rejected call. Names paths and
* expectations; reports received TYPES for structural issues and the capped
* offending VALUE for string-shape ones, because a shape complaint that will
* not say which string it is about cannot be acted on. `JSON.stringify` quotes
* and escapes the excerpt, so a multi-line argument stays one line.
*
* The parameter's own `description` follows on its own line when the schema
* declares one: that sentence is usually the whole correction.
*/
export function formatToolArgIssues(toolName, issues) {
const lines = issues.map((issue) => {
const where = issue.path === '' ? 'arguments' : `'${issue.path}'`;
const head = issue.expected === 'required'
? `- ${where} is required but missing`
: `- ${where}: expected ${issue.expected}, got ${issue.value === undefined ? issue.got : JSON.stringify(issue.value)}`;
return issue.hint === undefined ? head : `${head}\n ${where} is described as: ${issue.hint}`;
});
return (`Invalid arguments for tool '${toolName}' — the call was not executed.\n` +
`${lines.join('\n')}\n` +
`Fix the arguments to match the tool's input schema and call it again.`);
}
//# sourceMappingURL=toolArgsValidation.js.map