UNPKG

agentfootprint

Version:

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

320 lines • 16.7 kB
"use strict"; /** * artifacts/wants — ref ARGUMENTS at dispatch (Phase 2, Leg 1). * * A tool declares `wants: { dataset: 'dataset/rows' }` and the FRAMEWORK * resolves the ref before `execute`: the model speaks the ~26-char ticket as * the argument, dispatch redeems it under the run's own scope and kind-checks * the meta, and the handler receives the RESOLVED DATA (plus the meta on * `ctx.wanted`). A stale, unknown, or wrong-kind ref NEVER reaches the tool — * the model reads a teaching refusal that lists the live refs of the wanted * kind in scope (the innerRunRecords law: correct by naming what CAN resolve). * * The declare-and-push precedent, verb for verb: `needs` resolves a credential * before execute and injects `ctx.credential`; `wants` resolves data before * execute and injects it into the args. Both fail closed; neither lets the * model fabricate what the framework could not deliver. * * This module is the PURE half — validation at `defineTool` time and * resolution over the five-verb port. It never imports `core/`: the * tool-dispatch layer composes the scope, adapts the outcome onto the typed * `agentfootprint.artifacts.*` events (`op: 'dispatch'`), and refuses the * call. Core wires artifacts; never the reverse. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.wantsNeedsStoreRefusal = exports.resolveToolWants = exports.assertToolWants = void 0; const types_js_1 = require("./types.js"); /** * Refuse a `wants` declaration this library cannot honor, at definition time * — naming the tool and the fix, never at the first dispatch of the first * run. Checks, per declared argument: * • the kind is a non-empty string (a blank kind can never match a mint); * • when the tool's `inputSchema` declares `properties`, the argument * exists there (a wants-arg the model is never offered can never be * filled); * • when that property declares a `type`, it is `'string'` — the model * passes the REF, never the bytes, so any other type is a schema that * asks the model to inline what this feature exists to keep out. */ function assertToolWants(toolName, wants, inputSchema) { if (wants === undefined) return; const entries = Object.entries(wants); if (entries.length === 0) { throw new Error(`defineTool: tool '${toolName}' declares wants: {} — an empty declaration resolves ` + `nothing and reads as one that does. Name at least one argument ` + `(e.g. wants: { dataset: 'dataset/rows' }), or omit \`wants\` — absent means no ` + `argument is resolved, exactly as before.`); } const properties = inputSchema !== undefined && typeof inputSchema.properties === 'object' && inputSchema.properties !== null ? inputSchema.properties : undefined; for (const [argName, kind] of entries) { if (typeof kind !== 'string' || kind.trim().length === 0) { throw new Error(`defineTool: tool '${toolName}' declares wants.${argName} = ${JSON.stringify(kind)}, ` + `which is not an artifact kind. The value is the consumer vocabulary the resolved ` + `artifact's meta.kind must equal exactly (e.g. 'dataset/rows').`); } if (properties !== undefined && !(argName in properties)) { throw new Error(`defineTool: tool '${toolName}' declares wants.${argName}, but inputSchema.properties ` + `has no '${argName}' — the model is never offered that argument, so no ref could ` + `ever arrive to resolve. Add it to inputSchema (type: 'string' — the model passes ` + `the art_… ref), or drop it from \`wants\`.`); } const property = properties?.[argName]; const declaredType = typeof property === 'object' && property !== null ? property.type : undefined; if (declaredType !== undefined && declaredType !== 'string') { throw new Error(`defineTool: tool '${toolName}' declares wants.${argName}, but inputSchema declares ` + `that argument as type '${String(declaredType)}'. A wants-argument is spoken as the ` + `ref STRING (the model routes the ticket, never the bytes) — declare it ` + `{ type: 'string' }.`); } } } exports.assertToolWants = assertToolWants; /** How many live refs a refusal lists, and how many rows the listing pages * through to find them. Bounded — a refusal is a lesson, not a catalog. */ const LIVE_REF_LIST_LIMIT = 10; const LIVE_REF_SCAN_LIMIT = 200; /** * Which arguments this tool's OWN schema marks required — the half of the * contract that says whether an omitted ref is a choice or a hole. * * Read here rather than trusted to the args gate: `toolArgValidation` is an * agent-wide dial that an operator may set to `'warn'` or `'off'`, and a * `wants` declaration is a promise about DELIVERED DATA, not about schema * hygiene. The belt already refuses a non-string ref behind a disabled gate * (`'ref STRING'`); omission is the same class of hole and gets the same belt. */ function requiredArgNames(inputSchema) { const required = inputSchema?.required; if (!Array.isArray(required)) return new Set(); return new Set(required.filter((name) => typeof name === 'string')); } /** The live refs of `kind` in scope, newest first, bounded — what a refusal * names so the model can correct instead of retrying the same dead ref. */ async function liveRefsOfKind(store, scope, kind) { const found = []; let cursor; let scanned = 0; // Page cap beside the row cap: a misbehaving BYO store that returns empty // pages with a never-advancing cursor must not spin this loop forever — the // row-based SCAN_LIMIT only advances when pages carry rows. let pages = 0; do { const page = await store.list(scope, { ...(cursor !== undefined && { cursor }) }); for (const meta of page.artifacts) { scanned += 1; if (meta.kind === kind) found.push(meta); if (found.length >= LIVE_REF_LIST_LIMIT || scanned >= LIVE_REF_SCAN_LIMIT) return found; } cursor = page.cursor === cursor ? undefined : page.cursor; pages += 1; } while (cursor !== undefined && pages < 50); return found; } /** `art_h7Kq… ('Q3 rows', 6100 bytes)` — one listed live ref. */ function describeLiveRef(meta) { return `${meta.ref}${meta.label !== undefined ? ` ('${meta.label}', ${meta.bytes} bytes)` : ` (${meta.bytes} bytes)`}`; } /** What the model actually put in the argument, named the way a reader would * say it — `typeof null` is `'object'`, and a refusal that says "not a * object" about a `null` teaches the wrong correction. */ function describeSpokenValue(spoken) { if (spoken === null) return 'null'; if (Array.isArray(spoken)) return 'an array'; if (typeof spoken === 'string') return 'an empty string'; return `a ${typeof spoken}`; } /** The correcting clause: what CAN resolve, or the honest "nothing can". * * BOTH arms are PAST TENSE and bound to the refused call, because this clause * rides a TOOL RESULT — `resolveToolWants`'s combined refusal, which opens * "Tool 'X' was not executed" and is then re-read on every later call of the * turn. "No live artifacts exist … right now" was a forecast: the model mints * one two calls later and re-reads a sentence saying its data is not there. A * fact about the moment that call was refused cannot go stale. * * The LISTING arm carries the same defect pointing the other way, and kept it * three lines from the fix until the checker grew a rule for a census: "Live * '<kind>' refs in scope: …" is read as the scope, and by the time it is * re-read a ref it names may have been swept and a ref minted since is * missing from it. The model cannot see either error — a list looks like a * list. So the list says WHEN it was taken, and "Pass one of these" stays * useful because a swept ref refuses again with a fresher list. */ function liveRefsClause(kind, live) { if (live.length === 0) { return (`No live '${kind}' artifacts were in this run's scope when that call was refused — ` + `produce one first (a tool that stores '${kind}', or re-run the step that minted it).`); } return `These '${kind}' refs were live in this run's scope when that call was refused: ${live .map(describeLiveRef) .join(', ')}. Pass one of these.`; } // LENS · tool-result · persistent-history // reads: whether a ref resolves and its kind ← store.get; the live refs of a kind ← store.list, bounded 10/200 // LIVE source, same repair as present.ts: past tense, bound to the named call. inputSchema.required is // read again here as a deliberate second belt (`wants.ts` · requiredArgNames), not as a re-derivation of a fold. // law: may omit, never deny; every clause anchored to the call it was composed on. /** * Resolve one tool's declared `wants` against the run's scope — the whole * Leg-1 law in one place, shared by every dispatch door. * * An ABSENT declared argument is judged by the tool's own schema (9.38.0): * `required` there means the tool cannot do its job without the data, so * dispatch refuses BY NAME rather than executing a handler that believes its * payload was resolved; anything else is optional, and the model choosing not * to pass one is legitimate (`ctx.wanted` simply has no entry for it — absent * and empty are different facts). Pass the tool's `inputSchema` to have that * judged; omit it and every declared argument is treated as optional, which is * what a door that genuinely has no schema can honestly say. * * Per declared argument that is PRESENT in the call args: * • a non-string value is refused (`invalid-input`) — the argument is * spoken as the ref string, never the bytes; * • a ref that does not resolve in scope is refused * (`missing-or-expired`), listing the live refs of the wanted kind; * • a ref whose meta.kind differs from the declaration is refused * (`kind-mismatch`), naming both kinds and listing what would fit; * • a stored payload failing its digest re-check is refused * (`digest-mismatch`) — corrupt bytes never reach the tool as if whole. * * ALL declared args are judged before answering, so one refusal teaches the * whole correction rather than one argument per retry loop. */ async function resolveToolWants(store, scope, toolName, wants, args, inputSchema) { const refusals = []; const wanted = {}; const resolvedList = []; const nextArgs = { ...args }; const required = requiredArgNames(inputSchema); for (const [argName, kind] of Object.entries(wants)) { if (!(argName in args) || args[argName] === undefined) { // Optional: the model chose not to pass one. Nothing to resolve, and // nothing to complain about. if (!required.has(argName)) continue; // Required: the tool declared it wants '<kind>' HERE and its schema // declared the argument mandatory. Executing anyway would hand the // handler an argument it believes the framework resolved and did not — // accepted-and-silently-wrong, in the one place this feature exists to // make impossible. const live = await liveRefsOfKind(store, scope, kind); refusals.push({ argName, kind, reason: 'invalid-input', detail: `'${argName}' is required and no value was passed — this tool declares it wants a ` + `'${kind}' artifact there, and the framework resolves the ref BEFORE the tool runs, ` + `so the tool is never executed without it. Pass the art_… ref as '${argName}'. ` + `${liveRefsClause(kind, live)}`, }); continue; } const spoken = args[argName]; if (typeof spoken !== 'string' || spoken.trim().length === 0) { const live = await liveRefsOfKind(store, scope, kind); refusals.push({ argName, kind, reason: 'invalid-input', detail: `'${argName}' must be an artifact ref STRING (an art_… ticket), not ` + `${describeSpokenValue(spoken)} — this tool declares ` + `it wants '${kind}' there and the framework resolves the ref for you; never inline ` + `the data. ${liveRefsClause(kind, live)}`, }); continue; } let record; try { record = await store.get(scope, spoken); } catch (err) { if (err instanceof types_js_1.ArtifactIntegrityError) { refusals.push({ argName, kind, reason: 'digest-mismatch', ref: spoken, detail: `'${argName}' = ${spoken} failed its integrity re-check — the stored payload no ` + `longer matches its minted digest, and corrupt bytes are never delivered as ` + `whole. Re-create the artifact from its source, then pass the new ref.`, }); continue; } throw err; } if (record === null) { const live = await liveRefsOfKind(store, scope, kind); refusals.push({ argName, kind, reason: 'missing-or-expired', ref: spoken, detail: `'${argName}' = ${spoken} does not resolve in this run's scope — expired, swept, ` + `or never stored here. ${liveRefsClause(kind, live)}`, }); continue; } if (record.meta.kind !== kind) { const live = await liveRefsOfKind(store, scope, kind); refusals.push({ argName, kind, reason: 'kind-mismatch', ref: spoken, detail: `'${argName}' = ${spoken} resolves, but it is '${record.meta.kind}' and this ` + `argument wants '${kind}' — the wrong parcel for this ticket window. ` + `${liveRefsClause(kind, live)}`, }); continue; } nextArgs[argName] = record.data; wanted[argName] = record.meta; resolvedList.push({ argName, meta: record.meta }); } if (refusals.length > 0) { return { ok: false, refusals, refusal: `Tool '${toolName}' was not executed — its declared artifact argument` + `${refusals.length > 1 ? 's' : ''} did not resolve. ` + refusals.map((r) => r.detail).join(' '), }; } return { ok: true, args: nextArgs, wanted, resolved: resolvedList }; } exports.resolveToolWants = resolveToolWants; // LENS · tool-result · persistent-history // reads: the tool's own wants declaration only — it states configuration and cannot go stale // law: may omit, never deny; every clause anchored to the call it was composed on. /** * The teaching refusal for a `wants`-declaring tool dispatched where NO * artifact store is attached — fail-closed, naming the config fix, because * running the tool with the raw ref string where it expects resolved data * would be accepted-and-silently-wrong. (Agent dispatch refuses this at * BUILD for statically registered tools; this sentence serves the doors * that only meet the tool at dispatch — provider-served tools, `mcpServe`.) */ function wantsNeedsStoreRefusal(toolName, wants) { const declared = Object.entries(wants) .map(([arg, kind]) => `${arg}: '${kind}'`) .join(', '); return (`Tool '${toolName}' declares artifact arguments (wants: { ${declared} }) but no artifact ` + `store is attached here, so its refs cannot be resolved and the tool was not executed. ` + `This is configuration, not something to retry: attach a store ` + `(Agent.create({ ..., artifacts }) — inMemoryArtifacts(), fileArtifacts({ directory }), ` + `or sqliteArtifacts({ file })), or serve this tool from an agent that has one.`); } exports.wantsNeedsStoreRefusal = wantsNeedsStoreRefusal; //# sourceMappingURL=wants.js.map