agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
360 lines • 16.9 kB
JavaScript
/**
* 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.
*/
import { assertUnreservedSubflowSegment } from '../lib/conventions.js';
import { FlowChartExecutor, flowChart, } from 'footprintjs';
import { RunnerBase, makeRunId, underRunSignal } from '../core/RunnerBase.js';
import { normalizeRunInput } from '../core/runInput.js';
import { adoptParentRun, parentRunIn, readsParentRun, closeParentRun, openParentRun, reopenParentRun, withParentRun, } from '../core/memberRun.js';
import { requestOf } from '../core/requestOfRun.js';
import { ContextRecorder } from '../recorders/core/ContextRecorder.js';
import { streamRecorder } from '../recorders/core/StreamRecorder.js';
import { agentRecorder } from '../recorders/core/AgentRecorder.js';
import { compositionRecorder } from '../recorders/core/CompositionRecorder.js';
import { typedEmit } from '../recorders/core/typedEmit.js';
import { carryVerdict, childOutcome, raiseChildVerdict } from './childOutcome.js';
import { composedInput, readsMessageFromIfAny } from '../core/messageFrom.js';
import { adoptMemberRedaction } from '../redaction/declared.js';
export class Conditional extends 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`).
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`).
adoptMemberRedaction(this, branches.map((b) => b.runner));
this.fallbackId = fallbackId;
// It hands the run it serves to its members (`core/memberRun.ts`).
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 = requestOf(options, 'Conditional.run');
const runInput = 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 = openParentRun(this.currentRunContext.runId, runInput.identity, 'Conditional.run', request);
try {
const result = await 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).
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 = requestOf(options, 'Conditional.resume');
const parentRun = 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 underRunSignal(options, (engine) => executor.resume(checkpoint, input, engine));
return this.finalizeResult(executor, result);
}
finally {
closeParentRun(parentRun);
}
}
createExecutor(resumeLeg) {
this.currentRunContext = {
runStartMs: Date.now(),
runId: 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 FlowChartExecutor(spec, { scopeFactory: redaction.scopeFactoryFor(spec) });
redaction.applyTo(executor);
const dispatcher = this.getDispatcher();
executor.attachCombinedRecorder(new ContextRecorder({ dispatcher, getRunContext: getRunCtx, realWrites: redaction }));
executor.attachCombinedRecorder(streamRecorder({ dispatcher, getRunContext: getRunCtx }));
executor.attachCombinedRecorder(agentRecorder({ dispatcher, getRunContext: getRunCtx }));
executor.attachCombinedRecorder(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();
adoptParentRun(scope, args, 'Conditional');
scope.userMessage = args.message;
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;
}
}
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 = 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' ? composedInput(b.runner, input) : input;
// Every branch runs for this run (`core/memberRun.ts`).
return withParentRun(mapped, 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 = childOutcome(b.runner, sfOutput);
if ('verdict' in outcome)
return { result: '', ...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`).
raiseChildVerdict((key) => scope.$getValue(key));
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();
}
}
/**
* Fluent builder. Branches evaluate in registration order; first matching
* predicate wins. `.otherwise()` is the mandatory fallback.
*/
export 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.
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}'`);
}
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);
}
}
//# sourceMappingURL=Conditional.js.map