agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
365 lines • 17.6 kB
JavaScript
"use strict";
/**
* Conditional — routing composition: evaluates branches in order, runs the first match.
*
* Pattern: Builder (GoF) + Adapter over footprintjs's `addDeciderFunction`.
* Role: core-flow/ layer. Picks exactly ONE branch based on a predicate
* (sync function of input) OR an LLM decision. Chosen branch
* receives `{ message }` and returns a string.
* Emits: agentfootprint.composition.enter / exit +
* composition.route_decided (via compositionRecorder).
*
* v1 of this primitive supports `.when(id, predicate, runner)` + `.otherwise(id, runner)`.
* LLM-gated routing (`.whenLLM(id, prompt, runner)`) lands in Phase 5.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ConditionalBuilder = exports.Conditional = void 0;
const conventions_js_1 = require("../lib/conventions.js");
const footprintjs_1 = require("footprintjs");
const RunnerBase_js_1 = require("../core/RunnerBase.js");
const runInput_js_1 = require("../core/runInput.js");
const memberRun_js_1 = require("../core/memberRun.js");
const requestOfRun_js_1 = require("../core/requestOfRun.js");
const ContextRecorder_js_1 = require("../recorders/core/ContextRecorder.js");
const StreamRecorder_js_1 = require("../recorders/core/StreamRecorder.js");
const AgentRecorder_js_1 = require("../recorders/core/AgentRecorder.js");
const CompositionRecorder_js_1 = require("../recorders/core/CompositionRecorder.js");
const typedEmit_js_1 = require("../recorders/core/typedEmit.js");
const childOutcome_js_1 = require("./childOutcome.js");
const messageFrom_js_1 = require("../core/messageFrom.js");
const declared_js_1 = require("../redaction/declared.js");
class Conditional extends RunnerBase_js_1.RunnerBase {
name;
id;
branches;
fallbackId;
opts;
currentRunContext = {
runStartMs: 0,
runId: 'pending',
compositionPath: [],
};
constructor(opts, branches, fallbackId) {
super();
this.opts = opts;
this.name = opts.name ?? 'Conditional';
this.id = opts.id ?? 'conditional';
if (branches.length < 2) {
throw new Error('Conditional: must have at least one .when() branch plus an .otherwise()');
}
this.branches = branches;
// Holding a branch that reads `messageFrom`, it reads it too (`core/messageFrom.ts`).
(0, messageFrom_js_1.readsMessageFromIfAny)(this, branches.map((b) => b.runner));
// A branch's redaction policy covers this composition's run — the chosen
// branch runs on its executor (`src/redaction/declared.ts`).
(0, declared_js_1.adoptMemberRedaction)(this, branches.map((b) => b.runner));
this.fallbackId = fallbackId;
// It hands the run it serves to its members (`core/memberRun.ts`).
(0, memberRun_js_1.readsParentRun)(this);
// Eager chart construction — see `RunnerBase.initChart` JSDoc.
this.initChart(() => this.buildChart());
}
static create(opts = {}) {
return new ConditionalBuilder(opts);
}
// `getSpec()` inherited from RunnerBase — returns the cached chart.
// ─── UI group translation (L1b) ───────────────────────────────
getGroupTranslator() {
return this.opts.groupTranslator;
}
/** Conditional: one member per branch (.when / .otherwise), plus
* `extra.fallbackId` marking the otherwise branch. Per-method
* overrides (L1c) take precedence over the branch runner's own
* translator. */
buildUIGroupMetadata() {
const members = this.branches.map((b) => ({
memberId: b.id,
runner: b.runner,
uiGroup: b.groupTranslator !== undefined
? b.runner.getUIGroupWith(b.groupTranslator)
: b.runner.getUIGroup(),
}));
return {
kind: 'Conditional',
id: this.id,
name: this.name,
members,
extra: { fallbackId: this.fallbackId },
};
}
async run(input, options) {
// The incoming request, refused here — before anything moves — when
// requestContext() did not build it.
const request = (0, requestOfRun_js_1.requestOf)(options, 'Conditional.run');
const runInput = (0, runInput_js_1.normalizeRunInput)(input, 'Conditional.run');
const executor = this.createExecutor();
this.lastExecutor = executor;
// The run every member serves — built once, here, from THIS run's id.
const parentRun = (0, memberRun_js_1.openParentRun)(this.currentRunContext.runId, runInput.identity, 'Conditional.run', request);
try {
const result = await (0, RunnerBase_js_1.underRunSignal)(options, (engine) => executor.run({
input: { message: runInput.message, parentRun: parentRun.link },
...(engine ?? {}),
}));
return this.finalizeResult(executor, result);
}
finally {
// Released when the run returns — finished, failed or paused (a resume re-opens it).
(0, memberRun_js_1.closeParentRun)(parentRun);
}
}
async resume(checkpoint, input, options) {
// The incoming request, and the paused run its members serve (re-opened
// from the checkpoint's own record) — both refused before anything moves.
const request = (0, requestOfRun_js_1.requestOf)(options, 'Conditional.resume');
const parentRun = (0, memberRun_js_1.reopenParentRun)(checkpoint.sharedState, 'Conditional.resume', request);
try {
// The leg's policy — the paused run's marks — goes to ITS executor, never via the instance.
const resumeLeg = this.emitPauseResume(checkpoint, input);
const executor = this.createExecutor(resumeLeg);
this.lastExecutor = executor;
const result = await (0, RunnerBase_js_1.underRunSignal)(options, (engine) => executor.resume(checkpoint, input, engine));
return this.finalizeResult(executor, result);
}
finally {
(0, memberRun_js_1.closeParentRun)(parentRun);
}
}
createExecutor(resumeLeg) {
this.currentRunContext = {
runStartMs: Date.now(),
runId: (0, RunnerBase_js_1.makeRunId)(),
compositionPath: [`Conditional:${this.id}`],
};
// THIS run's context, captured: a run that overlaps it on this instance
// never restamps its events, so each is served under its own run's policy.
const runContext = this.currentRunContext;
const getRunCtx = () => runContext;
// The run's redaction (`src/redaction/`): every policy a branch declared.
const redaction = this.openRunRedaction(resumeLeg, getRunCtx);
// Reuse the cached chart built at constructor time.
const spec = this.getSpec();
const executor = new footprintjs_1.FlowChartExecutor(spec, { scopeFactory: redaction.scopeFactoryFor(spec) });
redaction.applyTo(executor);
const dispatcher = this.getDispatcher();
executor.attachCombinedRecorder(new ContextRecorder_js_1.ContextRecorder({ dispatcher, getRunContext: getRunCtx, realWrites: redaction }));
executor.attachCombinedRecorder((0, StreamRecorder_js_1.streamRecorder)({ dispatcher, getRunContext: getRunCtx }));
executor.attachCombinedRecorder((0, AgentRecorder_js_1.agentRecorder)({ dispatcher, getRunContext: getRunCtx }));
executor.attachCombinedRecorder((0, CompositionRecorder_js_1.compositionRecorder)({ dispatcher, getRunContext: getRunCtx }));
for (const r of this.attachedRecorders)
executor.attachCombinedRecorder(r);
return executor;
}
finalizeResult(executor, result) {
const paused = this.detectPause(executor, result);
if (paused)
return paused;
if (result instanceof Error)
throw result;
if (typeof result === 'string')
return result;
throw new Error('Conditional: unexpected result shape — expected string');
}
buildChart() {
const branches = this.branches;
const fallbackId = this.fallbackId;
const compositionId = this.id;
const compositionName = this.name;
const seed = (scope) => {
const args = scope.$getArgs();
(0, memberRun_js_1.adoptParentRun)(scope, args, 'Conditional');
scope.userMessage = args.message;
(0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.composition.enter', {
kind: 'Conditional',
id: compositionId,
name: compositionName,
childCount: branches.length,
});
};
/**
* Decider — pure sync function returning the chosen branch id.
* Fires composition.route_decided event with rationale so consumers
* can explain WHY the route was picked.
*/
const decider = (scope) => {
const input = {
message: scope.userMessage ?? '',
};
let chosen = fallbackId;
let rationale = `no .when() predicate matched — fell through to ${fallbackId}`;
for (const b of branches) {
if (b.predicate === undefined)
continue; // skip the fallback
if (b.predicate(input)) {
chosen = b.id;
rationale = `predicate for '${b.id}' returned true`;
break;
}
}
(0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.composition.route_decided', {
conditionalId: compositionId,
chosen,
rationale,
});
return chosen;
};
// Root description prefix `Conditional:` is the taxonomy marker —
// see FlowchartRecorder.mapTopologyToSteps for the consumer side.
const base = (0, footprintjs_1.flowChart)('Initialize', seed, 'seed', {
...(this.opts.structureRecorders !== undefined && {
structureRecorders: [...this.opts.structureRecorders],
}),
description: `Conditional: ${branches.length}-branch routing`,
});
let decList = base.addDeciderFunction('Route', decider, 'route', 'Conditional branch selection');
for (const b of branches) {
decList = decList.addSubFlowChartBranch(b.id, b.runner.getSpec(), b.name, {
inputMapper: (parent) => {
// The caller's own message — composed only when this composition was
// handed a composed one (`core/messageFrom.ts`).
const input = { message: parent.userMessage ?? '' };
const mapped = parent.messageFrom === 'composed' ? (0, messageFrom_js_1.composedInput)(b.runner, input) : input;
// Every branch runs for this run (`core/memberRun.ts`).
return (0, memberRun_js_1.withParentRun)(mapped, (0, memberRun_js_1.parentRunIn)(parent), b.runner);
},
// Branch's string return becomes sfOutput; propagate to parent
// as `result` for the Finalize stage to read.
// (The child reads its own outcome — `childOutcome.ts`: a verdict is
// carried onto the Conditional's state instead.)
outputMapper: (sfOutput) => {
const outcome = (0, childOutcome_js_1.childOutcome)(b.runner, sfOutput);
if ('verdict' in outcome)
return { result: '', ...(0, childOutcome_js_1.carryVerdict)(outcome.verdict) };
return { result: typeof outcome.answer === 'string' ? outcome.answer : '' };
},
});
}
let builder = decList.setDefault(fallbackId).end();
// After the decider + chosen branch returns, emit composition.exit
// and pass the branch's result as the executor's TraversalResult.
builder = builder.addFunction('Finalize', (scope) => {
// The chosen branch's return is carried as the previous stage's
// output. We read `scope.__branchResult` which the decider chain
// writes via outputMapper? Actually footprintjs threads the
// branch output as the NEXT stage's input argument — but we're
// using `addFunction` which gets the SAME scope.
//
// Easier: each branch writes `scope.result`, and Finalize reads it.
// But we don't control branch internals. Instead, read from the
// stage chain's most-recent return via the executor.
//
// Pragmatic approach: return an empty string here and rely on
// the decider branch being the LAST stage in its arm. Actually
// that doesn't work either — decider WITH branches means each
// branch IS a subflow, and THIS `Finalize` runs AFTER the branch
// joins. What we want is the BRANCH's return.
//
// footprintjs behavior: stages chained after a decider receive
// the chosen branch's subflow result via their first-arg.
// Unfortunately `addFunction` receives only scope, not the
// result.
//
// Solution: have each branch's outputMapper write scope.result,
// then read it here.
//
// A branch that ended on a verdict raises its error instead (`childOutcome.ts`).
(0, childOutcome_js_1.raiseChildVerdict)((key) => scope.$getValue(key));
(0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.composition.exit', {
kind: 'Conditional',
id: compositionId,
name: compositionName,
status: 'ok',
durationMs: Date.now() - this.currentRunContext.runStartMs,
});
return scope.result ?? '';
}, 'finalize', 'Conditional finalize');
return builder.build();
}
}
exports.Conditional = Conditional;
/**
* Fluent builder. Branches evaluate in registration order; first matching
* predicate wins. `.otherwise()` is the mandatory fallback.
*/
class ConditionalBuilder {
opts;
branches = [];
fallbackRegistered = false;
fallbackId = '';
seenIds = new Set();
constructor(opts) {
this.opts = opts;
}
/**
* Register a predicate-gated branch. `predicate` is a pure sync function
* of the Conditional's input; if it returns true, the corresponding
* runner executes. Branches evaluate in registration order.
*
* Fourth arg accepts EITHER a legacy bare `name` string OR a
* `ConditionalBranchOptions` bag containing `name` and/or a per-method
* `groupTranslator` override. The override applies ONLY to this
* branch's `member.uiGroup`.
*/
when(id, predicate, runner, nameOrOpts) {
if (this.seenIds.has(id)) {
throw new Error(`Conditional.when(): duplicate branch id '${id}'`);
}
// Branch ids are mounted verbatim as subflow segments (addSubFlowChartBranch
// in buildChart), so a consumer 'sf-' name would be read as framework
// plumbing by every downstream viewer instead of colliding loudly.
(0, conventions_js_1.assertUnreservedSubflowSegment)('Conditional.when()', 'branch id', id);
this.seenIds.add(id);
const opts = typeof nameOrOpts === 'string'
? ({ name: nameOrOpts })
: nameOrOpts ?? {};
const entry = {
id,
runner,
predicate,
name: opts.name ?? id,
...(opts.groupTranslator !== undefined && {
groupTranslator: opts.groupTranslator,
}),
};
this.branches.push(entry);
return this;
}
/**
* Register the fallback branch. Exactly ONE must be registered before build().
* Third arg accepts a legacy `name` string OR a `ConditionalBranchOptions`
* bag (same shape as `.when()`).
*/
otherwise(id, runner, nameOrOpts) {
if (this.fallbackRegistered) {
throw new Error('Conditional.otherwise(): already registered');
}
if (this.seenIds.has(id)) {
throw new Error(`Conditional.otherwise(): duplicate branch id '${id}'`);
}
(0, conventions_js_1.assertUnreservedSubflowSegment)('Conditional.otherwise()', 'branch id', id);
this.seenIds.add(id);
const opts = typeof nameOrOpts === 'string'
? ({ name: nameOrOpts })
: nameOrOpts ?? {};
const entry = {
id,
runner,
name: opts.name ?? id,
...(opts.groupTranslator !== undefined && {
groupTranslator: opts.groupTranslator,
}),
};
this.branches.push(entry);
this.fallbackId = id;
this.fallbackRegistered = true;
return this;
}
build() {
if (!this.fallbackRegistered) {
throw new Error('Conditional.build(): missing .otherwise() — every Conditional needs a fallback branch');
}
return new Conditional(this.opts, this.branches, this.fallbackId);
}
}
exports.ConditionalBuilder = ConditionalBuilder;
//# sourceMappingURL=Conditional.js.map