UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

407 lines • 23 kB
"use strict"; /** * 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). */ Object.defineProperty(exports, "__esModule", { value: true }); exports.defineTool = exports.assertGates = exports.assertComposedOf = exports.assertArgumentsFrom = exports.assertToolOwner = exports.assertResultKind = exports.COLUMN_TYPES = exports.assertResultColumns = exports.assertResultClass = exports.assertResultCeiling = exports.assertToolEffects = exports.inheritReadOnly = exports.readOnlyByDeclaration = exports.assertDisplayName = void 0; const wants_js_1 = require("../artifacts/wants.js"); const types_js_1 = require("../semantics/types.js"); const types_js_2 = require("../integrity/column-types/types.js"); Object.defineProperty(exports, "assertResultColumns", { enumerable: true, get: function () { return types_js_2.assertResultColumns; } }); Object.defineProperty(exports, "COLUMN_TYPES", { enumerable: true, get: function () { return types_js_2.COLUMN_TYPES; } }); const askComponent_js_1 = require("./askComponent.js"); const toolName_js_1 = require("../lib/toolName.js"); const displayName_js_1 = require("../lib/displayName.js"); const declare_js_1 = require("./declare.js"); const script_js_1 = require("./script.js"); const declaration_js_1 = require("../semantics/coverage/search/declaration.js"); const review_js_1 = require("../semantics/coverage/search/review.js"); const searchOf_js_1 = require("./searchOf.js"); const footprintjs_1 = require("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. */ function assertDisplayName(toolName, displayName) { if (displayName === undefined) return; const problem = (0, displayName_js_1.displayNameProblem)(`defineTool('${toolName}'): \`displayName\``, displayName); if (problem !== undefined) throw new Error(problem); } exports.assertDisplayName = assertDisplayName; /** 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. */ function readOnlyByDeclaration(tool) { return tool.effects === 'read' && tool.source === undefined && DECLARED_READ_ONLY.has(tool); } exports.readOnlyByDeclaration = readOnlyByDeclaration; /** Carry an app's read-only declaration through a library-owned, behavior-preserving wrapper. */ function inheritReadOnly(from, to) { if (readOnlyByDeclaration(from)) DECLARED_READ_ONLY.add(to); } exports.inheritReadOnly = inheritReadOnly; /** * 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). */ 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).'); } } exports.assertToolEffects = assertToolEffects; /** * 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. */ 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.`); } } } exports.assertResultCeiling = assertResultCeiling; /** * 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. */ function assertResultClass(toolName, resultClass) { if (resultClass === undefined) return; if (!types_js_1.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: ${types_js_1.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).`); } } exports.assertResultClass = assertResultClass; /** * 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. */ 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.`); } } exports.assertResultKind = assertResultKind; /** * 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. */ 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)}.`); } } exports.assertToolOwner = assertToolOwner; /** * 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. */ 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.`); } } } exports.assertArgumentsFrom = assertArgumentsFrom; /** * 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. */ 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.`); } } } exports.assertComposedOf = assertComposedOf; /** * 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. */ 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)}.`); } } exports.assertGates = assertGates; /** 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 || !(0, footprintjs_1.isDevMode)() || warnedSearchReview.has(toolName)) return; const overlaps = (0, review_js_1.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(); */ function defineTool(options) { (0, toolName_js_1.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. (0, types_js_2.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.`); } (0, askComponent_js_1.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. (0, wants_js_1.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. (0, declare_js_1.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 = (0, declaration_js_1.periodArgumentsOf)(options.period); (0, declaration_js_1.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. (0, script_js_1.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: (0, declaration_js_1.freezeSearch)(options.search) }), ...(options.effects !== undefined && { effects: options.effects }), ...(options.displayName !== undefined && { displayName: options.displayName.trim() }), ...(options.script !== undefined && { script: (0, script_js_1.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) (0, searchOf_js_1.rememberSearch)(tool, tool.search); return tool; } exports.defineTool = defineTool; //# sourceMappingURL=tools.js.map