UNPKG

agentfootprint

Version:

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

249 lines • 10.9 kB
/** * Structural lint rules (RFC-002 block C2) — the PLUGGABLE RULE PACK. * * Pattern: Strategy list — each rule is a plain `{ id, check }` object; * `defaultStructuralRules` is OUR pack, and consumers add / * remove / replace freely via `AnalyzeToolCatalogOptions.rules`. * Parameterizable rules ship as FACTORIES (`descriptionRule`, * `saysWhatNotWhenRule`, …) returning a configured `LintRule`. * Role: `src/tool-lint/` leaf. Pure functions over `CatalogTool`; * no embedder, no I/O. * * Every rule encodes a FIELD FINDING from real catalogs (the host-app SAN * triage agent's 29-tool catalog was the seed corpus): * * 1. description-missing-or-short — the model can only guess from a name. * 2. says-what-not-when — describes WHAT the tool returns but gives the * model no cue for WHEN to pick it over a sibling (the #1 cause of * twin-tool confusion: 'get_fcns_database' vs 'influx_get_fcns_database'). * 3. enum-in-prose — string params whose legal values are listed in prose * ("avg_iops | peak_iops | mbps") instead of a JSON-Schema `enum` the * model (and validators, see #9 tool-args validation) can act on. * 4. optional-param-undocumented — optional params whose omission has * meaning (fabric-wide sweep vs one switch) but whose schema never * says so; the model can't reason about leaving them out. * * Honest claim: these are token/regex HEURISTICS. They flag review * prompts, not certainties — expect (rare) false positives and tune via * the factory options instead of deleting the rule. */ /** Read `properties` / `required` out of a JSON-Schema-ish inputSchema, * tolerating absent or malformed shapes (rules must never throw). */ function readObjectSchema(tool) { const schema = tool.inputSchema; const props = schema?.properties; const properties = props !== null && typeof props === 'object' ? Object.entries(props).filter((entry) => entry[1] !== null && typeof entry[1] === 'object') : []; const required = new Set(Array.isArray(schema?.required) ? schema.required.filter((r) => typeof r === 'string') : []); return { properties, required }; } function hasWholeWord(text, word) { return new RegExp(`(?:^|[^a-z0-9])${escapeRegExp(word.toLowerCase())}(?:[^a-z0-9]|$)`).test(text.toLowerCase()); } function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Missing description → `error` (the model can only guess from the * name). Present but shorter than `minChars` → `warn` (too short to * differentiate from siblings). */ export function descriptionRule(options = {}) { const minChars = options.minChars ?? 40; return { id: 'description-missing-or-short', check(tool) { const description = tool.description?.trim() ?? ''; if (description.length === 0) { return [ { rule: 'description-missing-or-short', tool: tool.name, severity: 'error', message: 'tool has no description — the model can only guess from the name', }, ]; } if (description.length < minChars) { return [ { rule: 'description-missing-or-short', tool: tool.name, severity: 'warn', message: `description is ${description.length} chars (< ${minChars}) — too short to differentiate this tool from its siblings`, }, ]; } return []; }, }; } // ── Rule 2 — says WHAT, not WHEN ───────────────────────────────────── /** RFC-002 C2 heuristic cue list — temporal/conditional words whose * presence suggests the description says WHEN to use the tool. */ export const DEFAULT_WHEN_CUES = [ 'for', 'when', 'after', 'first', 'fallback', 'only', ]; /** * A description with NO temporal/conditional cue token usually describes * WHAT the tool returns but never WHEN to pick it — the #1 cause of * twin-tool confusion. Heuristic by design: tune `cueTokens` rather than * dropping the rule. Skips tools with no description (rule 1's finding). */ export function saysWhatNotWhenRule(options = {}) { const cues = options.cueTokens ?? DEFAULT_WHEN_CUES; return { id: 'says-what-not-when', check(tool) { const description = tool.description?.trim() ?? ''; if (description.length === 0) return []; if (cues.some((cue) => hasWholeWord(description, cue))) return []; return [ { rule: 'says-what-not-when', tool: tool.name, severity: 'warn', message: 'description says WHAT the tool returns but gives no cue for WHEN to use it ' + `(no ${cues.map((c) => `'${c}'`).join('/')}) — add the choice condition, ` + 'e.g. "Use when …" / "Call FIRST" / "FALLBACK if …"', }, ]; }, }; } // ── Rule 3 — enum described in prose ───────────────────────────────── const IDENT = '[A-Za-z][A-Za-z0-9_.-]*'; /** `avg_iops | peak_iops | mbps` — two or more pipe-separated literals. */ const PIPE_LIST = new RegExp(`(${IDENT})(?:\\s*\\|\\s*(?:${IDENT}))+`); /** `one of: red, green, blue` — comma lists only behind an explicit * values marker, so free-form examples ("e.g. 1h, 24h") don't flag. */ const COMMA_LIST = new RegExp(`(?:one of|allowed values?|valid values?|options|values)\\s*:?\\s*(${IDENT}(?:\\s*,\\s*${IDENT})+)`, 'i'); /** * A string param whose description enumerates its legal values in prose * (pipe-separated literals, or comma lists behind "one of"/"allowed * values") should declare a JSON-Schema `enum` instead — the model picks * reliably from enums, and arg validators (#9) can enforce them. The * field case: the host app's `influx_get_port_ranking.metric` = * `"avg_iops | peak_iops | mbps"`. */ export function enumInProseRule() { return { id: 'enum-in-prose', check(tool) { const findings = []; const { properties } = readObjectSchema(tool); for (const [param, prop] of properties) { if (prop.enum !== undefined) continue; if (prop.type !== undefined && prop.type !== 'string') continue; const description = typeof prop.description === 'string' ? prop.description : ''; if (description.length === 0) continue; const literals = extractProseLiterals(description); if (literals === undefined) continue; findings.push({ rule: 'enum-in-prose', tool: tool.name, severity: 'warn', param, message: `param '${param}' lists its legal values in prose ("${description.slice(0, 80)}") — declare them as a JSON-Schema enum so the model picks reliably`, suggestion: `"enum": ${JSON.stringify(literals)}`, }); } return findings; }, }; } function extractProseLiterals(description) { const pipe = PIPE_LIST.exec(description); if (pipe) { return pipe[0].split('|').map((v) => v.trim()); } const comma = COMMA_LIST.exec(description); if (comma) { return comma[1].split(',').map((v) => v.trim()); } return undefined; } // ── Rule 4 — optional param whose omission is undocumented ─────────── /** Words that signal the description DOES say what omission means. */ export const DEFAULT_OMISSION_CUES = [ 'optional', 'default', 'defaults', 'omit', 'omitted', 'if not', 'when not', 'absent', 'all', 'entire', 'every', 'fallback', ]; /** * An optional param's omission usually MEANS something (host app: * `influx_get_interface_counters` without `switch_name` = fabric-wide * sweep) — but the model can only reason about leaving a param out if * the description says so. No description at all, or one with no * omission cue, gets a `warn`. */ export function optionalParamRule(options = {}) { const cues = options.omissionCues ?? DEFAULT_OMISSION_CUES; return { id: 'optional-param-undocumented', check(tool) { const findings = []; const { properties, required } = readObjectSchema(tool); for (const [param, prop] of properties) { if (required.has(param)) continue; const description = typeof prop.description === 'string' ? prop.description.trim() : ''; if (description.length === 0) { findings.push({ rule: 'optional-param-undocumented', tool: tool.name, severity: 'warn', param, message: `optional param '${param}' has no description — say what happens when it is omitted (a default? a broader scope?)`, }); } else if (!cues.some((cue) => hasWholeWord(description, cue))) { findings.push({ rule: 'optional-param-undocumented', tool: tool.name, severity: 'warn', param, message: `optional param '${param}' is described but never says what omission means — add e.g. "optional — defaults to …" / "omit for all …"`, }); } } return findings; }, }; } // ── The default pack ───────────────────────────────────────────────── /** * OUR rule pack, built with default options. Compose your own: * * rules: [...defaultStructuralRules, myRule] // add * rules: defaultStructuralRules.filter(r => r.id !== '…') // remove * rules: [descriptionRule({ minChars: 80 }), …] // re-tune */ export const defaultStructuralRules = [ descriptionRule(), saysWhatNotWhenRule(), enumInProseRule(), optionalParamRule(), ]; //# sourceMappingURL=rules.js.map