agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
123 lines • 6.19 kB
JavaScript
/**
* act — PUBLIC. One bundle for every place a rule may speak.
*
* Pattern: Facade over five doors, with the facade's KEYS type-locked to the
* list of moments they cover.
* Role: core/ layer. `.act()` builds nothing new: it validates a bundle
* and calls `.messageMiddleware()`, `.toolMiddleware()` and
* `.window()` with exactly what a person would have passed by hand.
* Everything about a run is decided by those doors, here and there
* alike.
* Emits: N/A.
*
* ## Why a bundle at all, when the doors already exist
*
* Because the doors are findable one at a time and the POSTURE is not. An
* agent's governance currently reads as four unrelated calls scattered
* through a builder chain, and the question a reviewer actually has —
* "what does this agent do at each point in its loop?" — has no place to be
* answered. `.act({ … })` is that place: five optional keys, named for the
* five moments, so autocomplete on an empty object teaches the loop.
*
* ## The completeness lock
*
* The keys of {@link ActOptions} are pinned, at compile time, against
* `ActKey<LoopMoment>` — the moment list, camel-cased. Add a sixth moment to
* `LOOP_MOMENTS` and this file stops compiling until the bundle grows the key
* for it; delete a key and the same. A surface that claims to be complete has
* to be made unable to fall behind, or the claim is just a sentence in a doc.
*
* The runtime half is derived from the same list: the accepted key set is
* `LOOP_MOMENTS.map(actKeyFor)`, so a typo'd key is refused by a validator
* that cannot drift from the type it is validating.
*/
import { messageAt } from './middleware/messagePhase.js';
import { actKeyFor, LOOP_MOMENTS } from '../../lib/moments.js';
const _keysAreExactlyTheMoments = true;
void _keysAreExactlyTheMoments;
/** The keys `.act()` accepts, derived from the moment list rather than typed. */
export const ACT_KEYS = LOOP_MOMENTS.map(actKeyFor);
function assertList(value, key) {
if (!Array.isArray(value)) {
throw new Error(`AgentBuilder.act: '${key}' expects an array of middleware, got ${typeof value}. ` +
`Wrap a single rule in an array — the bundle is a list per moment.`);
}
return value;
}
function assertNamedHook(mw, key, hook) {
if (mw === null ||
typeof mw !== 'object' ||
typeof mw.name !== 'string' ||
mw.name.length === 0 ||
typeof mw[hook] !== 'function') {
throw new Error(`AgentBuilder.act: every '${key}' entry needs a non-empty \`name\` and a \`${hook}(...)\` ` +
`method. The name is what every ledger row and event says decided the call, so it ` +
`cannot be blank` +
(hook === 'onToolCall'
? `; a rule that only has \`onToolResult\` belongs under 'afterTool'.`
: hook === 'onToolResult'
? `; a rule that only has \`onToolCall\` belongs under 'beforeTool'.`
: `.`));
}
}
/**
* Validate a bundle and turn it into the arguments the five doors take.
*
* Pure — it throws or it returns; it touches no builder state. `.act()` is
* the caller that hands the result to the doors, which is what makes the
* sugar provably the same agent.
*/
export function resolveAct(options) {
if (options === null || typeof options !== 'object' || Array.isArray(options)) {
throw new Error(`AgentBuilder.act: expected an options object, got ${Array.isArray(options) ? 'an array' : typeof options}. The keys are ${ACT_KEYS.join(', ')}.`);
}
for (const key of Object.keys(options)) {
if (!ACT_KEYS.includes(key)) {
throw new Error(`AgentBuilder.act: unknown key '${key}'. The loop has ${LOOP_MOMENTS.length} moments and ` +
`this bundle has one key for each: ${ACT_KEYS.join(', ')}. A key nobody reads is a ` +
`governance rule that silently never runs, so it is refused here rather than ignored.`);
}
}
// ── The message halves ──────────────────────────────────────────────
// A link named at BOTH phases is attached once, unguarded — which is the
// plain `.messageMiddleware(mw)` spelling, and the one that files one row
// per phase instead of two.
const input = assertList(options.input ?? [], 'input');
const output = assertList(options.output ?? [], 'output');
for (const mw of input)
assertNamedHook(mw, 'input', 'onMessage');
for (const mw of output)
assertNamedHook(mw, 'output', 'onMessage');
const message = [];
for (const mw of input) {
message.push(output.includes(mw) ? mw : messageAt('input', mw));
}
for (const mw of output) {
if (!input.includes(mw))
message.push(messageAt('output', mw));
}
// ── The tool moments ────────────────────────────────────────────────
// One list, walked forwards for calls and backwards for results. The
// buckets are named for the MOMENTS (`afterTool` ⇄ `'after-tool'`); the
// hooks are named for what they RECEIVE (`onToolResult`). The bucket says
// where a rule is MEANT to speak and is checked for the hook it names; what
// it actually does is decided by the hooks it has, so a rule can never
// silently fail to run because it was written in the other bucket.
const beforeTool = assertList(options.beforeTool ?? [], 'beforeTool');
const afterTool = assertList(options.afterTool ?? [], 'afterTool');
for (const mw of beforeTool)
assertNamedHook(mw, 'beforeTool', 'onToolCall');
for (const mw of afterTool)
assertNamedHook(mw, 'afterTool', 'onToolResult');
const tool = [...beforeTool];
for (const mw of afterTool) {
if (!tool.includes(mw))
tool.push(mw);
}
return {
message,
tool,
...(options.window !== undefined && { window: options.window }),
};
}
//# sourceMappingURL=act.js.map