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
JavaScript
/**
* 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