agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
157 lines • 7.64 kB
JavaScript
/**
* tools/script — a tool's declaration that it RUNS a program, and the one rule
* that judges the declaration.
*
* Pattern: Map. `Tool.script` is plain data on the tool, judged by ONE assert
* at the three doors a declaration crosses (the `askOrAssume`
* precedent, `tools/declare.ts`): definition (`tools.ts` ·
* `defineTool`), dispatch (`scriptOf`, for a Tool built by hand or
* served by a ToolProvider, which never passes `defineTool`) and MCP
* ingest (`mcp/toolExtras.ts` · `readToolExtras`).
* Role: tools/ layer leaf (L7). Imports nothing from the agent.
* Emits: N/A.
*
* ## Declared, never inferred
*
* A tool is code-running because it SAYS so. The library never reads it off a
* tool's name (`run_code`), a property named `code` or the description. A tool
* that runs code and does not declare it has no submissions on the record, so
* a script the model shows for it is prose, as it always was. `codeRunnerTool`
* declares `{ argument: 'code', language }` on the tool it builds.
*
* What the declaration arms (design: docs/design/2026-10-script-artifact.md):
* the dispatch loop files one `script` row per call of the tool while
* `.namesAndNumbersFromEvidence({ code: 'match' })` or
* `.scriptTravelsWithTheAnswer()` is on — the program SUBMITTED to the tool,
* and what the runner reported. Never evidence for a value.
*
* @example
* ```ts
* const runSql = defineTool({
* name: 'run_sql',
* description: 'Run a read-only SQL query and print the rows.',
* inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
* script: { argument: 'query', language: 'sql' },
* execute: async ({ query }) => db.read(query),
* });
* ```
*/
import { isDevMode } from 'footprintjs';
const MAX_LANGUAGE_CHARS = 64;
const KEYS = ['argument', 'language'];
/** Whether a unit may sit in a fence's info string: printable, no backtick. */
function infoUnitAllowed(unit) {
if (unit < 0x20 || unit === 0x7f || unit === 0x60)
return false;
// Line and paragraph separators end a line for some readers.
return unit !== 0x2028 && unit !== 0x2029;
}
/**
* The one judge of a `script` declaration: refuses, naming the tool, the field
* and the fix — a declaration the dispatch loop could not honour, never one it
* would quietly repair.
*
* - a plain object holding only `argument` and `language`;
* - `argument` names a property of the input schema whose `type` is `'string'`
* (a program is text), and not a `wants` argument (that one carries an
* artifact ref, then its data — never the program);
* - `language`, when present, is one plain token the fence can carry.
*/
export function assertToolScript(toolName, script, inputSchema, wants) {
if (script === undefined)
return;
const at = `defineTool('${toolName}'): \`script\``;
if (script === null || typeof script !== 'object' || Array.isArray(script)) {
throw new Error(`${at} must be { argument, language? } — the string argument that carries the program ` +
`and its language. Got ${script === null ? 'null' : Array.isArray(script) ? 'an array' : typeof script}.`);
}
const unknown = Object.keys(script).filter((k) => !KEYS.includes(k));
if (unknown.length > 0) {
throw new Error(`${at} has unknown key(s) ${unknown.map((k) => `'${k}'`).join(', ')}. ` +
'It holds `argument` (the string argument carrying the program) and `language`.');
}
const { argument, language } = script;
if (typeof argument !== 'string' || argument.length === 0) {
throw new Error(`${at}.argument must name the ONE string argument that carries the program — got ` +
`${JSON.stringify(argument) ?? typeof argument}.`);
}
const properties = inputSchema?.properties;
const property = properties !== null && typeof properties === 'object' && Object.hasOwn(properties, argument)
? properties[argument]
: undefined;
if (property === null ||
typeof property !== 'object' ||
property.type !== 'string') {
throw new Error(`${at}.argument is '${argument}', which the input schema does not declare as a string ` +
`property. Declare \`properties.${argument}: { type: 'string' }\` — the program is ` +
'the text the model passes there.');
}
if (wants !== undefined && Object.hasOwn(wants, argument)) {
throw new Error(`${at}.argument is '${argument}', which is a \`wants\` argument: it carries an artifact ` +
'ref, and then the artifact’s data — never the program. Name the argument that ' +
'holds the code.');
}
if (language === undefined)
return;
if (typeof language !== 'string' || language.length === 0) {
throw new Error(`${at}.language must be a non-empty string (\`python\`, \`sql\`) when present — got ` +
`${JSON.stringify(language) ?? typeof language}.`);
}
if (language.length > MAX_LANGUAGE_CHARS) {
throw new Error(`${at}.language is ${language.length} characters; at most ${MAX_LANGUAGE_CHARS}. It is ` +
'the fence’s info string, one token.');
}
for (let i = 0; i < language.length; i++) {
if (!infoUnitAllowed(language.charCodeAt(i))) {
throw new Error(`${at}.language holds a line break, a control character or a backtick. It becomes ` +
'the info string of the fence the library renders the program in, which may carry ' +
'none of them.');
}
}
}
/** A frozen copy of a well-formed declaration: the two fields, nothing else. */
export function copyToolScript(script) {
return Object.freeze({
argument: script.argument,
...(script.language !== undefined && { language: script.language }),
});
}
/** One verdict per tool object: a judged declaration, or `null` when refused. */
const verdicts = new WeakMap();
/** Tools already warned about at the dispatch door — one warning per tool per process. */
const warned = new WeakSet();
/**
* THE DISPATCH DOOR — the tool's `script` declaration, judged once per tool
* object, or `undefined` when it declares none.
*
* A tool built by hand or served by a ToolProvider never passed `defineTool`,
* so its declaration is judged here by the same assert. One that fails it is
* NOT a code-running tool for the record (no row is filed, a block the model
* shows for it is prose) — never half-honoured — and dev mode names the rule
* once, the MCP ingest law (`mcp/toolExtras.ts`): the rail stays unarmed and
* the developer is told which and why.
*/
export function scriptOf(tool) {
if (tool === undefined || tool.script === undefined)
return undefined;
const cached = verdicts.get(tool);
if (cached !== undefined)
return cached ?? undefined;
let verdict;
try {
assertToolScript(tool.schema.name, tool.script, tool.schema.inputSchema, tool.wants);
verdict = copyToolScript(tool.script);
}
catch (error) {
verdict = null;
if (isDevMode() && !warned.has(tool)) {
warned.add(tool);
// eslint-disable-next-line no-console
console.warn(`agentfootprint: tool '${tool.schema.name}' declares a \`script\` the library cannot ` +
`read, so its calls file no script row — ${error instanceof Error ? error.message : String(error)}`);
}
}
verdicts.set(tool, verdict);
return verdict ?? undefined;
}
//# sourceMappingURL=script.js.map