UNPKG

agentfootprint

Version:

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

165 lines • 8.25 kB
/** * Agent validators — pure helper functions extracted from Agent.ts. * * These run at Agent construction time (eagerly, so misconfiguration * fails fast at `.build()`) and during stage execution (safeStringify * for tool-result formatting). * * Pure functions, no class state — extracted for readability and * isolated testability. The Agent class imports + invokes these in * its constructor and stage handlers. */ import { isDevMode } from 'footprintjs'; import { toWireJson } from '../../lib/wireJson.js'; /** * Validate that every memory definition has a unique id. Each memory * writes to its own scope key (`memoryInjection_${id}`); duplicates * silently overwrite, leading to data loss that's hard to debug. * * Throws on collision so `Agent.build()` fails fast at construction. */ export function validateMemoryIdUniqueness(memories) { const seen = new Set(); for (const m of memories) { if (seen.has(m.id)) { throw new Error(`Agent: duplicate memory id '${m.id}'. Each memory needs a unique id to keep ` + 'its scope key (`memoryInjection_${id}`) collision-free.'); } seen.add(m.id); } } /** * Validate `maxIterations`. The historical silent clamp to 50 existed because * footprintjs's recursive traversal hit its depth wall around iteration 71 — * footprintjs 9.0.0's trampoline removed that wall (loops run on a flat * stack), so the cap is gone. The lower bound (1) still prevents a * 0-iteration agent; large values are the consumer's COST choice (each * iteration is an LLM call) — a dev-mode warning flags budgets above 100. * The Agent passes matching headroom to the engine's own loop-iteration * limit so it can never fire below the agent's budget. */ export function clampIterations(n) { if (!Number.isInteger(n) || n < 1) return 1; if (n > 100 && isDevMode()) { // eslint-disable-next-line no-console console.warn(`agentfootprint: maxIterations=${n} — each iteration is one LLM call; ` + 'this is a cost budget, not a perf limit (footprintjs 9 runs loops flat). ' + 'Set deliberately.'); } return n; } /** * Validate tool-name uniqueness across `.tool()`-registered tools + * every Skill's `inject.tools[]`. The LLM dispatches by `tool.schema.name` * (the wire format), so any collision silently shadows execution. * * Called eagerly in the Agent constructor so `Agent.build()` throws * immediately, not on first `run()`. * * `read_skill` is reserved when ≥1 Skill is registered — collisions * with consumer tools throw. */ export function validateToolNameUniqueness(registry, injections) { // Static registry: unique within itself. The Agent.tool() builder // method already throws on per-call duplicates; this is the // belt-and-suspenders check at build time. const staticNames = new Set(); for (const entry of registry) { if (staticNames.has(entry.name)) { throw new Error(`Agent: duplicate tool name '${entry.name}' in .tool() registry. ` + `Tool names must be unique within the static registry.`); } staticNames.add(entry.name); } // `read_skill` is reserved when any Skill is registered. Collisions // with consumer-supplied tools break the auto-attach path. const skills = injections.filter((i) => i.flavor === 'skill'); if (skills.length > 0 && staticNames.has('read_skill')) { throw new Error(`Agent: tool name 'read_skill' is reserved when ≥1 Skill is registered. ` + `Rename your custom 'read_skill' tool or unregister it.`); } // Per-skill check: a skill's `inject.tools` array must be internally // unique (no duplicate names within the same skill — that's a // skill authoring bug). Across skills, sharing a Tool reference is // EXPECTED and supported — common tools (e.g., a `flogi_lookup` // used by multiple investigation skills) appear in multiple skills' // tool arrays. Only one skill is active at a time (or, when several // are active, deduped by name + reference at runtime). Sharing the // same Tool object across skills is the supported pattern; sharing // a Tool NAME with a DIFFERENT execute function is the actual bug — // we detect that here too. const seenByName = new Map(); for (const skill of skills) { const intraSkill = new Set(); for (const tool of skill.inject.tools ?? []) { const name = tool.schema.name; if (intraSkill.has(name)) { throw new Error(`Agent: skill '${skill.id}' lists tool '${name}' more than once in its ` + `inject.tools array. Each skill's tools must be unique within itself.`); } intraSkill.add(name); // Skill tools collide with the static .tool() registry → ambiguous dispatch if (staticNames.has(name)) { throw new Error(`Agent: skill '${skill.id}' tool '${name}' collides with the static .tool() ` + `registry. Either rename the skill's tool or remove the static registration.`); } // Same name across skills with DIFFERENT Tool objects = ambiguous when // both skills active. Same name + SAME Tool reference = supported sharing. const prior = seenByName.get(name); if (prior && prior !== tool) { throw new Error(`Agent: tool name '${name}' is declared by multiple skills with different ` + `Tool implementations. Skills MAY share the SAME Tool reference across ` + `their inject.tools arrays (deduped at dispatch); they may NOT register ` + `different functions under the same name (ambiguous dispatch).`); } seenByName.set(name, tool); } } } /** * What a tool that returned nothing puts in the conversation. * * Self-describing on purpose: the model reads this string as the tool's * result, and it has to be able to tell "this tool does not report a value" * apart from "this tool returned the word undefined". `''` would say neither. */ export const NO_TOOL_VALUE = '(this tool returned no value)'; /** * JSON.stringify with circular-ref protection. Tool results are untrusted — * a hostile/buggy tool returning a cyclic object must not crash the run. * Falls back to '[unstringifiable: <reason>]' so the LLM still sees that * the tool ran and produced something unusable. * * **Total, since 8.18.0.** `JSON.stringify` returns `undefined` — not a * string — for `undefined`, a function and a symbol, so the old body's * `: string` was a promise it could not keep. An `async execute()` that did * its work and forgot to `return` produced a `role: 'tool'` message with * `content: undefined`, which crashed the next turn's messages slot with an * anonymous `TypeError` five frames inside the engine. A tool is allowed to * do something and report nothing; the conversation just has to say so. * * The truth is unaffected: `agentfootprint.stream.tool_end` still carries * the tool's REAL return value, `undefined` included (every serializing sink * renders an Error in it by the same wire rule). */ export function safeStringify(value) { if (value === undefined) return NO_TOOL_VALUE; try { // The wire rule (`lib/wireJson.ts`): this string is the `role: 'tool'` // message — sent to the model provider AND kept in `history`, so in every // snapshot, recording and checkpoint. An Error in a tool result (a client // library's, with its request headers) is written as its name, message, // code and cause — never its custom properties, `toJSON` output or stack. const json = toWireJson(value); // `undefined` again for a function or a symbol — describe it rather than // hand the next stage a hole. return json === undefined ? `[unserializable: ${typeof value}]` : json; } catch (err) { const reason = err instanceof Error ? err.message : String(err); return `[unstringifiable: ${reason}]`; } } //# sourceMappingURL=validators.js.map