UNPKG

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
"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