UNPKG

agentfootprint

Version:

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

231 lines (228 loc) • 10.2 kB
"use strict"; /** * Tool-lint CLI core (RFC-002 block C3 — the CI gate). * * Pattern: humble shell — `bin/agentfootprint-lint-tools.mjs` is a * 3-line wrapper; ALL behavior (arg parsing, catalog coercion, * report, exit code) lives here so it is unit-testable without * spawning a process. * Role: `src/tool-lint/`. Reads ONE JSON file of tools, prints a * report, returns the process exit code: * 0 — report.ok * 1 — findings failed the gate (!ok) * 2 — usage / input error (bad flags, unreadable file, * unrecognized JSON shape) * * ## Embedder & gating honesty * * The CLI has no way to receive a consumer embedder, so it uses the * built-in deterministic mock (char-frequency, offline, dependency-free) * for the similarity RANKING — and, by default, does NOT gate on it: * without `--threshold`, similarity is report-only (relative ordering + * watch hints) and the exit code reflects structural findings alone. * Pass `--threshold` to make confusable pairs fail the gate — you own * the calibration at that point (start from * `MOCK_EMBEDDER_CALIBRATION.confusabilityThreshold` = 0.94). For real * embedder gating, use `analyzeToolCatalog` from * `agentfootprint/observe` in a small script instead. */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.runToolLintCli = exports.coerceCatalog = void 0; const mockEmbedder_js_1 = require("../memory/embedding/mockEmbedder.js"); const analyze_js_1 = require("./analyze.js"); const format_js_1 = require("./format.js"); const USAGE = `usage: agentfootprint-lint-tools <tools.json> [options] <tools.json> JSON file with your tool catalog. Accepted shapes: [{ name, description, inputSchema? }] (plain / MCP tool) { tools: [...] } (MCP tools/list result) [{ type: 'function', function: {...} }] (OpenAI) [{ name, description, input_schema }] (Anthropic) --threshold <num> gate on confusable pairs at this cosine (mock-embedder starting point: ${analyze_js_1.MOCK_EMBEDDER_CALIBRATION.confusabilityThreshold}). Without it, similarity is REPORT-ONLY and only structural findings gate. --watch-band <num> advisory band below the threshold (default ${analyze_js_1.MOCK_EMBEDDER_CALIBRATION.watchBand} with --threshold) --strict structural warnings also fail the gate --no-similarity skip the similarity analysis entirely --top <n> ranked pairs to print (default 10) --json print the full report as JSON instead of text exit codes: 0 ok · 1 findings failed the gate · 2 usage/input error`; /** * Normalize any of the recognized tool-list JSON shapes to the lint's * plain catalog. Throws (with a shape description) on unrecognized * input — the CLI maps that to exit code 2. */ function coerceCatalog(json) { // { tools: [...] } — MCP `tools/list` result envelope. const list = Array.isArray(json) ? json : json !== null && typeof json === 'object' && Array.isArray(json.tools) ? json.tools : undefined; if (list === undefined) { throw new Error('expected a JSON array of tools or { tools: [...] }'); } return list.map((raw, index) => { if (raw === null || typeof raw !== 'object') { throw new Error(`tools[${index}] is not an object`); } const entry = raw; // OpenAI: { type: 'function', function: { name, description, parameters } } const fn = entry.type === 'function' && entry.function !== null && typeof entry.function === 'object' ? entry.function : entry; const name = fn.name; if (typeof name !== 'string' || name.length === 0) { throw new Error(`tools[${index}] has no string 'name'`); } const description = typeof fn.description === 'string' ? fn.description : undefined; // inputSchema (MCP/ours) | input_schema (Anthropic) | parameters (OpenAI) const schema = fn.inputSchema ?? fn.input_schema ?? fn.parameters; const inputSchema = schema !== null && typeof schema === 'object' ? schema : undefined; return { name, ...(description !== undefined ? { description } : {}), ...(inputSchema !== undefined ? { inputSchema } : {}), }; }); } exports.coerceCatalog = coerceCatalog; function parseArgs(argv) { let file; let threshold; let watchBand; let strict = false; let similarity = true; let top = 10; let json = false; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; const numberFlag = (name) => { const value = Number(argv[++i]); if (!Number.isFinite(value)) throw new Error(`${name} expects a number`); return value; }; if (arg === '--threshold') threshold = numberFlag('--threshold'); else if (arg === '--watch-band') watchBand = numberFlag('--watch-band'); else if (arg === '--strict') strict = true; else if (arg === '--no-similarity') similarity = false; else if (arg === '--top') top = numberFlag('--top'); else if (arg === '--json') json = true; else if (arg === '--help' || arg === '-h') throw new Error(USAGE); else if (arg.startsWith('-')) throw new Error(`unknown flag '${arg}'\n\n${USAGE}`); else if (file === undefined) file = arg; else throw new Error(`unexpected extra argument '${arg}'\n\n${USAGE}`); } if (file === undefined) throw new Error(USAGE); return { file, ...(threshold !== undefined ? { threshold } : {}), ...(watchBand !== undefined ? { watchBand } : {}), strict, similarity, top, json, }; } /** * Run the lint CLI. Returns the exit code (never calls `process.exit` — * the bin wrapper assigns it to `process.exitCode`). */ async function runToolLintCli(argv, io = { // eslint-disable-next-line no-console stdout: (line) => console.log(line), // eslint-disable-next-line no-console stderr: (line) => console.error(line), }) { let args; try { args = parseArgs(argv); } catch (error) { io.stderr(error.message); return 2; } let catalog; try { // Lazy node:fs import (browser-compat): `agentfootprint/observe` // re-exports this module, and a TOP-LEVEL node:fs/promises import // detonates any browser bundle at module-eval (found by the // agent-playground; same lazy-gating pattern as audit's node:crypto). // The CLI path is the only consumer that touches the filesystem. const { readFile } = await Promise.resolve().then(() => __importStar(require('node:fs/promises'))); catalog = coerceCatalog(JSON.parse(await readFile(args.file, 'utf8'))); } catch (error) { io.stderr(`agentfootprint-lint-tools: ${args.file}: ${error.message}`); return 2; } // Without --threshold the mock-embedder similarity is REPORT-ONLY: // rank pairs at an unreachable threshold (no 'confusable'/'watch' // verdicts) so only the relative-ordering section prints. const gateOnSimilarity = args.threshold !== undefined; const threshold = args.threshold ?? Infinity; const watchBand = args.watchBand ?? (gateOnSimilarity ? analyze_js_1.MOCK_EMBEDDER_CALIBRATION.watchBand : 0); const report = await (0, analyze_js_1.analyzeToolCatalog)(catalog, { ...(args.similarity ? { embedder: (0, mockEmbedder_js_1.mockEmbedder)() } : {}), confusabilityThreshold: threshold, watchBand, failOn: args.strict ? 'warn' : 'error', }); if (args.json) { io.stdout(JSON.stringify(report, null, 2)); } else { if (args.similarity) { io.stdout(gateOnSimilarity ? '⚠ similarity uses the built-in deterministic mock embedder — you own the ' + 'calibration of --threshold (cosine ranges are per-embedder; mock compresses ' + 'prose to ~0.85–0.97). Trust relative ordering first.' : 'ℹ similarity is REPORT-ONLY (no --threshold): ranked pairs below are the ' + 'relative-ordering view from the built-in mock embedder. Pass --threshold ' + `(mock starting point ${analyze_js_1.MOCK_EMBEDDER_CALIBRATION.confusabilityThreshold}) to gate on confusable pairs.`); io.stdout(''); } io.stdout((0, format_js_1.formatToolCatalogReport)(report, { topPairs: args.top })); } return report.ok ? 0 : 1; } exports.runToolLintCli = runToolLintCli; //# sourceMappingURL=cli.js.map