agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
401 lines • 22.1 kB
JavaScript
/**
* Tool types — Agent's tool-call contract.
*
* Pattern: Strategy (GoF) — each Tool is a strategy for "how to execute
* this named operation given these args".
* Role: Consumer-facing shape. Agent.tool(...) accepts these.
* Emits: N/A (types only).
*/
import { assertToolWants } from '../artifacts/wants.js';
import { RESULT_CLASSES } from '../semantics/types.js';
import { assertResultColumns, COLUMN_TYPES, } from '../integrity/column-types/types.js';
import { assertAskComponent } from './askComponent.js';
import { warnIfInvalidToolName } from '../lib/toolName.js';
import { displayNameProblem } from '../lib/displayName.js';
import { assertAskOrAssume } from './declare.js';
import { assertToolScript, copyToolScript } from './script.js';
import { assertToolSearch, freezeSearch, periodArgumentsOf, } from '../semantics/coverage/search/declaration.js';
import { searchReviewItems } from '../semantics/coverage/search/review.js';
import { rememberSearch } from './searchOf.js';
import { isDevMode } from 'footprintjs';
/**
* Refuse a `displayName` a person should not read, at definition time — the
* rule (`lib/displayName.ts` · `displayNameProblem`) asked for a tool built by
* `defineTool` and, at registration, for one assembled by hand.
*/
export function assertDisplayName(toolName, displayName) {
if (displayName === undefined)
return;
const problem = displayNameProblem(`defineTool('${toolName}'): \`displayName\``, displayName);
if (problem !== undefined)
throw new Error(problem);
}
/** The tools whose `effects: 'read'` was written in `defineTool` — the app's own word. */
const DECLARED_READ_ONLY = new WeakSet();
/**
* Whether the APP declared this tool read-only: `effects: 'read'` written in
* `defineTool`, on a local tool (no `source`). Tool metadata, an MCP
* annotation or a remote `readOnlyHint` never answer yes.
*/
export function readOnlyByDeclaration(tool) {
return tool.effects === 'read' && tool.source === undefined && DECLARED_READ_ONLY.has(tool);
}
/** Carry an app's read-only declaration through a library-owned, behavior-preserving wrapper. */
export function inheritReadOnly(from, to) {
if (readOnlyByDeclaration(from))
DECLARED_READ_ONLY.add(to);
}
/**
* Refuse an `effects` this library cannot honour — any value but `'read'` at
* `defineTool`, and any `effects` on a remote tool at registration
* (`effects-on-remote`: a remote tool's word never relaxes a check).
*/
export function assertToolEffects(toolName, effects, remote = false) {
if (effects === undefined)
return;
if (effects !== 'read') {
throw new Error(`defineTool('${toolName}'): \`effects\` is ${JSON.stringify(effects)} — the one ` +
"declaration is 'read' (this tool changes nothing). Omit it for a tool that may write.");
}
if (remote) {
throw new Error(`tool '${toolName}': \`effects\` on a tool that arrived over MCP or from a ToolProvider — ` +
'only the app may declare a tool read-only, in defineTool on a local tool ' +
'(effects-on-remote).');
}
}
/**
* Refuse a `resultCeiling` this library cannot honor, at definition time —
* naming the tool and the fix, never failing at the first oversized result of
* the first run. Exported beside `lib/toolName.ts` · `assertValidToolName` for consumers
* assembling `Tool` objects by hand.
*/
export function assertResultCeiling(toolName, ceiling) {
if (ceiling === undefined)
return;
// Destructured through a fallback because this rule is also asked about
// values NO COMPILER vetted — a `_meta` bag from a foreign MCP server
// (`readToolExtras`). A `null` or a number must reach the teaching refusal
// below, not blow up on the destructuring on the way to it.
const { maxChars, narrowBy } = (ceiling ?? {});
if (!Number.isFinite(maxChars) || !Number.isInteger(maxChars) || maxChars <= 0) {
throw new Error(`defineTool: tool '${toolName}' declares resultCeiling.maxChars = ${String(maxChars)}, ` +
`which is not a positive whole number of characters. The ceiling is the size over ` +
`which the model reads a refusal instead of the result — to have no ceiling, omit ` +
`\`resultCeiling\` (absent means results are never measured, exactly as before).`);
}
if (narrowBy !== undefined) {
const usable = Array.isArray(narrowBy) &&
narrowBy.length > 0 &&
narrowBy.every((n) => typeof n === 'string' && n.trim().length > 0);
if (!usable) {
throw new Error(`defineTool: tool '${toolName}' declares resultCeiling.narrowBy = ` +
`${JSON.stringify(narrowBy)}, which names nothing the refusal could suggest. ` +
`List at least one parameter name (non-empty strings, e.g. ['limit', 'fields']), ` +
`or drop the field — omitting it is how "no suggestions" is said.`);
}
}
}
/**
* Refuse a `resultClass` outside the closed set, at definition time — naming
* the tool, the value and the whole vocabulary (the `assertResultCeiling`
* law: a declaration this library cannot honor fails HERE, never at the
* first gate run of the first CI pipeline). Exported beside it for consumers
* assembling `Tool` objects by hand.
*/
export function assertResultClass(toolName, resultClass) {
if (resultClass === undefined)
return;
if (!RESULT_CLASSES.includes(resultClass)) {
throw new Error(`defineTool: tool '${toolName}' declares resultClass '${String(resultClass)}', which is ` +
`not a class this library has. The classes are: ${RESULT_CLASSES.join(', ')} — each ` +
`carries a rule \`check:semantics\` can prove ('triage'/'inventory' results must ` +
`declare coverage). To declare no class, omit the field (the semantic-envelope rules ` +
`still apply to any result carrying the af_semantics marker).`);
}
}
/**
* The COLUMN-TYPE CONTRACT's rule and vocabulary, re-exported HERE.
*
* They are DEFINED in `src/integrity/column-types/types.ts`, because
* `src/integrity/` is a strict leaf that imports nothing outside itself and
* the check has to be able to read the words. They are re-exported here
* because `defineTool` and the MCP ingest both reach for their rules through
* `tools/tools.ts` — one door for every declaration's rule, and no second copy
* of any of them anywhere.
*/
export { assertResultColumns, COLUMN_TYPES, };
/**
* Refuse a `resultKind` that could never be redeemed, at definition time —
* naming the tool and the fix (the `assertResultCeiling` / `assertResultClass`
* law: a declaration this library cannot honor fails HERE, never at the first
* oversized result of the first run). Exported beside them for consumers
* assembling `Tool` objects by hand.
*
* The one rule is non-blankness, and it is not a formality: the kind is what a
* `wants` argument is matched against by exact string equality, so a blank or
* whitespace-only kind mints a ticket no declaration can ever name — the same
* refusal `assertToolWants` raises on the consuming end, raised on the
* producing end too. There is deliberately NO charset or shape rule: the kind
* is the consumer's vocabulary, and the library does not own it.
*/
export function assertResultKind(toolName, resultKind) {
if (resultKind === undefined)
return;
if (typeof resultKind !== 'string' || resultKind.trim().length === 0) {
throw new Error(`defineTool: tool '${toolName}' declares resultKind ${JSON.stringify(resultKind)}, which ` +
`is not a kind anything could want. It is the artifact kind a PLACED result is minted ` +
`under, matched by exact string equality against a consuming tool's ` +
`\`wants\` (e.g. 'dataset/rows'), so a blank kind is a ticket no argument can redeem. ` +
`Name the kind your consumers declare, or omit the field — omitting it mints ` +
`\`tool-result/${toolName}\`, exactly as before.`);
}
}
/**
* Refuse an `owner` stamp with a blank half, at definition time — naming the
* tool and what a blank half would do.
*
* An identity edge with a blank half is worse than none: the Context Integrity
* checks JOIN on it, so a blank `kind` or `id` joins the wrong subjects rather
* than no subjects. Extracted from `defineTool` (9.71.0) so the MCP ingest
* boundary can hold the SAME line on a declaration that arrived over the wire —
* one rule, one message, two doors.
*
* Deliberately NOT a closed-set check on `kind`: the union is enforced by the
* compiler for anyone calling `defineTool`, and the value is only ever read
* into a prose reason (`owned by <kind> '<id>'`), never switched on.
*/
export function assertToolOwner(toolName, owner) {
if (owner === undefined)
return;
// Same reason as `assertResultCeiling`: this is also asked about untyped
// values from a foreign MCP server, so a scalar or a `null` must land on the
// message rather than on a TypeError from the destructuring.
const { kind, id } = (owner ?? {});
if (typeof owner !== 'object' ||
typeof kind !== 'string' ||
kind.length === 0 ||
typeof id !== 'string' ||
id.length === 0) {
throw new Error(`defineTool('${toolName}'): \`owner\` must carry a non-empty kind and id — it is ` +
`the identity edge integrity checks join on, and a blank half joins the wrong ` +
`subjects. Got ${JSON.stringify(owner)}.`);
}
}
/**
* Refuse an `argumentsFrom` edge that could never join, at definition time.
*
* The dangling-reference and unsupported-argument checks join on these names,
* so a blank one — or a tool grounded by itself — would join the wrong subjects
* or none at all. Extracted from `defineTool` (9.71.0) for the same reason as
* {@link assertToolOwner}: the MCP ingest boundary enforces the identical rule
* on a declaration a remote server sent.
*/
export function assertArgumentsFrom(toolName, argumentsFrom) {
if (argumentsFrom === undefined)
return;
// The array check is part of the rule, not a formality: a foreign server can
// put a bare string here, and `for…of` over one would walk its CHARACTERS
// and refuse them one letter at a time.
if (!Array.isArray(argumentsFrom) || argumentsFrom.length === 0) {
throw new Error(`defineTool('${toolName}'): \`argumentsFrom\` must name at least one tool — ` +
`omitting the field is how "no grounds" is said.`);
}
for (const ground of argumentsFrom) {
if (typeof ground !== 'string' || ground.length === 0) {
throw new Error(`defineTool('${toolName}'): \`argumentsFrom\` entries must be non-empty tool ` +
`names. Got ${JSON.stringify(ground)}.`);
}
if (ground === toolName) {
throw new Error(`defineTool('${toolName}'): \`argumentsFrom\` names the tool itself — a tool ` +
`cannot be its own argument ground.`);
}
}
}
/**
* Refuse a `composedOf` list that could never be drift-checked, at definition
* time — the {@link assertArgumentsFrom} law applied to composition: the
* agent-build gate joins on these names, and a blank one — or a tool composed
* of itself — would join the wrong subjects or none. The REGISTRATION check
* (is every named ingredient actually registered?) deliberately does NOT
* happen here: the ingredients need not exist before this tool is defined,
* and only the agent build sees the complete catalog.
*/
export function assertComposedOf(toolName, composedOf) {
if (composedOf === undefined)
return;
// Same reason as `assertArgumentsFrom`: this rule is also asked about
// untyped values from a foreign MCP server, and `for…of` over a bare string
// would walk its CHARACTERS.
if (!Array.isArray(composedOf) || composedOf.length === 0) {
throw new Error(`defineTool('${toolName}'): \`composedOf\` must name at least one ingredient tool — ` +
`omitting the field is how "not composed" is said.`);
}
for (const ingredient of composedOf) {
if (typeof ingredient !== 'string' || ingredient.length === 0) {
throw new Error(`defineTool('${toolName}'): \`composedOf\` entries must be non-empty tool names. ` +
`Got ${JSON.stringify(ingredient)}.`);
}
if (ingredient === toolName) {
throw new Error(`defineTool('${toolName}'): \`composedOf\` names the tool itself — a tool cannot ` +
`be its own ingredient.`);
}
}
}
/**
* Refuse a `gates` declaration that is not a boolean, at definition time.
* Trivial for anyone the compiler vets; load-bearing at the MCP ingest
* boundary, where a foreign server can put anything under the key and a
* truthy string would silently declare a gate nobody wrote.
*/
export function assertGates(toolName, gates) {
if (gates === undefined)
return;
if (typeof gates !== 'boolean') {
throw new Error(`defineTool('${toolName}'): \`gates\` must be a boolean — \`true\` (this tool's ` +
`procedure can raise an approval gate), \`false\` (declared gate-free), or omitted ` +
`(nothing declared). Got ${JSON.stringify(gates)}.`);
}
}
/** Tools already told about a `said-overlap` — one dev warning per tool per process. */
const warnedSearchReview = new Set();
/**
* Dev mode: name each `said-overlap` of a search once — a review item, never a
* refusal (the `check:semantics` gate lists it too, with `source-said`).
*/
function warnSearchReview(toolName, search) {
if (search === undefined || !isDevMode() || warnedSearchReview.has(toolName))
return;
const overlaps = searchReviewItems(search).filter((i) => i.code === 'said-overlap');
if (overlaps.length === 0)
return;
warnedSearchReview.add(toolName);
// eslint-disable-next-line no-console
console.warn(`agentfootprint search: tool '${toolName}' — ` +
overlaps.map((i) => `${i.field}: ${i.message}`).join(' ') +
' (said-overlap; this warning fires once per tool per process.)');
}
/**
* Ergonomic builder for `Tool`. Equivalent to constructing an object
* literal with `schema` + `execute`, but flatter and safer — the name
* + description live alongside the executor so they can't drift.
*
* @example
* const weather = defineTool<{ city: string }, string>({
* name: 'weather',
* description: 'Get current weather for a city',
* inputSchema: {
* type: 'object',
* properties: { city: { type: 'string' } },
* required: ['city'],
* },
* execute: async ({ city }) => `${city}: 72°F sunny`,
* });
*
* const agent = Agent.create({ provider }).tool(weather).build();
*/
export function defineTool(options) {
warnIfInvalidToolName(options.name);
// A ceiling that cannot cap fails HERE, naming the tool — not at the first
// oversized result of the first run.
assertResultCeiling(options.name, options.resultCeiling);
// A class outside the closed set fails HERE too — not at the first
// `check:semantics` run of the first CI pipeline.
assertResultClass(options.name, options.resultClass);
// A placed-result kind nothing could want fails HERE too — not at the first
// oversized result of the first run with placement configured.
assertResultKind(options.name, options.resultKind);
// A column type this library has no rule for fails HERE too — not at the
// first rowset of the first armed run, where the symptom would be a check
// that quietly judges nothing.
assertResultColumns(options.name, options.resultColumns);
// A decision component for a gate that never fires is configuration that
// lies — configured-and-inert looks exactly like configured-and-working
// (the `.checkIn({ scorer })`-with-minimal-evidence precedent). And a
// malformed one fails HERE, naming the tool, not at the first tripped gate
// of the first consequential call.
if (options.checkInComponent !== undefined) {
if (options.checkIn === undefined) {
throw new Error(`defineTool('${options.name}'): \`checkInComponent\` has no effect without \`checkIn\`. ` +
`The component rides the check-in ask — which screen collects the decision — and ` +
`this tool declares no check-in, so it would never be shown to anyone. Declare ` +
`\`checkIn: 'always'\` (or a predicate), or drop the component.`);
}
assertAskComponent(options.checkInComponent, `defineTool('${options.name}') checkInComponent`);
}
// A wants-arg the schema never offers (or offers as a non-string) fails
// HERE too, naming the argument — not as a ref that never arrives. Judged
// against the RESOLVED schema: an omitted inputSchema defaults to empty
// properties, which offers no argument for any ref to arrive through.
assertToolWants(options.name, options.wants, options.inputSchema ?? { type: 'object', properties: {} });
// An identity edge with a blank half is worse than none: a checker would
// join on it and compare the wrong subjects. Refused HERE, naming the tool.
assertToolOwner(options.name, options.owner);
// The grounds edge is judged at the same door and for the same reason: the
// dangling-reference check joins on these names, and a blank one — or a
// tool grounded by itself — would join the wrong subjects or none.
assertArgumentsFrom(options.name, options.argumentsFrom);
// Composition edges get the same shape discipline; the REGISTRATION check
// (does every ingredient exist?) waits for the agent build, where the
// catalog is complete.
assertComposedOf(options.name, options.composedOf);
assertGates(options.name, options.gates);
// The inputs layer's rules (honesty layer 2) — judged against the RESOLVED
// schema, each property by its OWN schema (never the root `required`), and
// refused here naming the tool and the argument, not at the first call.
assertAskOrAssume(options.name, options.askOrAssume, options.period, options.inputSchema ?? { type: 'object', properties: {} }, options.wants);
// What one call searches — every property classified, every default and
// qualifier declared, the statement text held to a sentence's standard.
// Refused HERE, naming the tool and the code, not at the first "not found".
const periodArguments = periodArgumentsOf(options.period);
assertToolSearch(options.name, options.search, {
inputSchema: options.inputSchema ?? { type: 'object', properties: {} },
...(periodArguments !== undefined && { periodArguments }),
});
warnSearchReview(options.name, options.search);
assertToolEffects(options.name, options.effects);
// The app's words reach a person verbatim — refused HERE, naming the tool.
assertDisplayName(options.name, options.displayName);
// The program's argument must be a declared string — refused HERE, naming the tool.
assertToolScript(options.name, options.script, options.inputSchema ?? { type: 'object', properties: {} }, options.wants);
const tool = {
schema: {
name: options.name,
description: options.description,
inputSchema: options.inputSchema ?? { type: 'object', properties: {} },
},
...(options.needs && { needs: options.needs }),
...(options.wants !== undefined && { wants: options.wants }),
// The call-site predicate is typed to TArgs; the stored Tool keeps the
// non-generic shape so it widens into `Tool[]` registries.
...(options.checkIn !== undefined && { checkIn: options.checkIn }),
...(options.checkInComponent !== undefined && { checkInComponent: options.checkInComponent }),
// Copied verbatim, never inferred — an empty array is a tool that declared
// it touches nothing, which is a different statement from saying nothing.
...(options.capabilities !== undefined && { capabilities: options.capabilities }),
...(options.resultCeiling !== undefined && { resultCeiling: options.resultCeiling }),
...(options.resultClass !== undefined && { resultClass: options.resultClass }),
...(options.resultKind !== undefined && { resultKind: options.resultKind }),
...(options.resultColumns !== undefined && { resultColumns: options.resultColumns }),
...(options.owner !== undefined && { owner: options.owner }),
...(options.argumentsFrom !== undefined && { argumentsFrom: options.argumentsFrom }),
...(options.composedOf !== undefined && { composedOf: options.composedOf }),
...(options.gates !== undefined && { gates: options.gates }),
...(options.repeatedWhen !== undefined && { repeatedWhen: options.repeatedWhen }),
...(options.askOrAssume !== undefined && { askOrAssume: options.askOrAssume }),
...(options.period !== undefined && { period: options.period }),
// Stored frozen, its value words a null-prototype map (`freezeSearch`).
...(options.search !== undefined && { search: freezeSearch(options.search) }),
...(options.effects !== undefined && { effects: options.effects }),
...(options.displayName !== undefined && { displayName: options.displayName.trim() }),
...(options.script !== undefined && { script: copyToolScript(options.script) }),
execute: options.execute,
};
// The app's own word — the one `effects: 'read'` a later check may honour.
if (tool.effects === 'read')
DECLARED_READ_ONLY.add(tool);
// Judged above — remembered so the registration funnel never judges it twice.
if (tool.search !== undefined)
rememberSearch(tool, tool.search);
return tool;
}
//# sourceMappingURL=tools.js.map