UNPKG

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