UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

299 lines • 14.3 kB
/** * Sequence — sequential composition of runners (steps chained one after another). * * Pattern: Builder (GoF) + Adapter over footprintjs's `addSubFlowChartNext`. * Role: core-flow/ layer — pure control flow, no LLM deps. * Each step's output becomes the next step's input; default * mapping is string chaining (step N's return → step N+1's * `{ message }`). Custom mapping via `.mapBetween(fn)` between * any two steps. * Emits: agentfootprint.composition.enter / exit (via compositionRecorder). */ import { FlowChartExecutor, flowChart, } from 'footprintjs'; import { RunnerBase, makeRunId, underRunSignal } from '../core/RunnerBase.js'; import { normalizeRunInput } from '../core/runInput.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 { composedInput, readsMessageFromIfAny } from '../core/messageFrom.js'; import { adoptParentRun, parentRunIn, readsParentRun, closeParentRun, openParentRun, reopenParentRun, withParentRun, } from '../core/memberRun.js'; import { requestOf } from '../core/requestOfRun.js'; import { adoptMemberRedaction } from '../redaction/declared.js'; import { carryVerdict, childOutcome, raiseChildVerdict } from './childOutcome.js'; /** Default string→{message} mapper used between consecutive steps. */ const defaultMapBetween = (prev) => ({ message: prev }); export class Sequence extends RunnerBase { name; id; steps; opts; currentRunContext = { runStartMs: 0, runId: 'pending', compositionPath: [], }; constructor(opts, steps) { super(); this.opts = opts; this.name = opts.name ?? 'Sequence'; this.id = opts.id ?? 'sequence'; if (steps.length === 0) { throw new Error('Sequence: must have at least one .step()'); } this.steps = steps; // A Sequence holding a step that reads `messageFrom` reads it too, so a // composed message handed to it reaches its first step (`core/messageFrom.ts`). readsMessageFromIfAny(this, steps.map((s) => s.runner)); // A step's redaction policy covers this composition's run — the steps run // on its executor, never their own (`src/redaction/declared.ts`). adoptMemberRedaction(this, steps.map((s) => s.runner)); // 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 SequenceBuilder(opts); } // `getSpec()` inherited from RunnerBase — returns the cached chart. // ─── UI group translation (L1b) ─────────────────────────────── getGroupTranslator() { return this.opts.groupTranslator; } /** Sequence is a flat ordered list of steps. One member per step, * preserving definition order so the consumer can render them * linearly (default Lens UX). Per-method overrides (L1c) take * precedence over the step runner's own translator. */ buildUIGroupMetadata() { const members = this.steps.map((s) => ({ memberId: s.id, runner: s.runner, uiGroup: s.groupTranslator !== undefined ? s.runner.getUIGroupWith(s.groupTranslator) : s.runner.getUIGroup(), })); return { kind: 'Sequence', id: this.id, name: this.name, members, }; } async run(input, options) { // The incoming request, refused here — before anything moves — when // requestContext() did not build it. const request = requestOf(options, 'Sequence.run'); const runInput = normalizeRunInput(input, 'Sequence.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, 'Sequence.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, 'Sequence.resume'); const parentRun = reopenParentRun(checkpoint.sharedState, 'Sequence.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: [`Sequence:${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 step declared — // the steps run inside THIS executor, so this run's one rule covers them. 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('Sequence: unexpected result shape — expected string'); } buildChart() { const steps = this.steps; const compositionId = this.id; const compositionName = this.name; const seed = (scope) => { const args = scope.$getArgs(); scope.current = args.message; adoptParentRun(scope, args, 'Sequence'); typedEmit(scope, 'agentfootprint.composition.enter', { kind: 'Sequence', id: compositionId, name: compositionName, childCount: steps.length, }); }; // Root description prefix `Sequence:` is the taxonomy marker — // downstream consumers (Lens, FlowchartRecorder) detect composition // primitives via the `<Kind>:` prefix convention. See // FlowchartRecorder.mapTopologyToSteps for the consumer side. let builder = flowChart('Seed', seed, 'seed', { ...(this.opts.structureRecorders !== undefined && { structureRecorders: [...this.opts.structureRecorders], }), description: `Sequence: ${steps.length}-step pipeline`, }); // Mount each step as a subflow via addSubFlowChartNext. The step's // input comes from parent.current (mapped via mapFromPrev); the // step's return becomes parent.current (via outputMapper). steps.forEach((step, index) => { builder = builder.addSubFlowChartNext(`step-${step.id}`, step.runner.getSpec(), step.id, { // A step after the first is handed an EARLIER STEP'S output — another // runner's words, marked for a runner that reads the marker // (`core/messageFrom.ts` · `composedInput`); every other runner gets // the input it always did. inputMapper: (parent) => { // A step that ended on a verdict hands nothing on: the run raises its // error here, before the next step runs (`childOutcome.ts`). raiseChildVerdict((key) => parent[key]); const input = step.mapFromPrev(parent.current ?? ''); const mapped = index === 0 && parent.messageFrom !== 'composed' ? input : composedInput(step.runner, input); // Every step runs for this run (`core/memberRun.ts`). return withParentRun(mapped, parentRunIn(parent), step.runner); }, // `sfOutput` is the subflow's TraversalResult — for Runner-backed // subflows whose last stage returns a string, sfOutput IS that // string. We pipe it into parent.current for the next step's // inputMapper to pick up. // The child reads its own outcome (`childOutcome.ts`): a verdict is // carried onto the Sequence's state, an answer piped on as before. outputMapper: (sfOutput) => { const outcome = childOutcome(step.runner, sfOutput); if ('verdict' in outcome) return { current: '', ...carryVerdict(outcome.verdict) }; return { current: typeof outcome.answer === 'string' ? outcome.answer : '' }; }, }); }); // Final stage: emit composition.exit and return the current string // so executor.run() yields it as the TraversalResult. builder = builder.addFunction('Finalize', (scope) => { // The LAST step ended on a verdict — no next step's hand-off to raise it. raiseChildVerdict((key) => scope.$getValue(key)); const current = scope.current ?? ''; typedEmit(scope, 'agentfootprint.composition.exit', { kind: 'Sequence', id: compositionId, name: compositionName, status: 'ok', durationMs: Date.now() - this.currentRunContext.runStartMs, }); return current; }, 'finalize', 'Sequence finalize'); return builder.build(); } } /** * Fluent builder. Reads as natural English: * Sequence.create().step('a', A).pipeVia(fn).step('b', B).build() * → "Sequence: step A, pipe via fn, step B." * * `step(id, runner)` adds a sequential step. `pipeVia(fn)` customises * the transformation of the previous step's output before it feeds the * next step (otherwise the default string-chain mapper is used). */ export class SequenceBuilder { opts; steps = []; /** Pending pipeVia transformer for the NEXT step (consumed on .step()). */ pendingPipe; seenIds = new Set(); constructor(opts) { this.opts = opts; } /** * Add a step. Runner must accept `{ message: string }` and return `string`. * First step receives the Sequence input; subsequent steps receive the * previous step's output (via the default string-chain mapper, or via * the transformer set by a preceding `.pipeVia(fn)` call). * * Optional third arg `opts.groupTranslator` overrides the runner's * own constructor-level translator for THIS step only — only its * `member.uiGroup` flips to the override's output. */ step(id, runner, opts) { if (this.seenIds.has(id)) { throw new Error(`Sequence.step(): duplicate step id '${id}'`); } this.seenIds.add(id); const mapFromPrev = this.pendingPipe ?? defaultMapBetween; this.pendingPipe = undefined; this.steps.push({ id, runner, mapFromPrev, ...(opts?.groupTranslator !== undefined && { groupTranslator: opts.groupTranslator, }), }); return this; } /** * Transform the previous step's string output before it reaches the * next step. Consumed once by the next `.step()` call. Default * mapping is `(prev) => ({ message: prev })`. * * Reads as English: `.step('a', A).pipeVia(fn).step('b', B)` * → "step A, pipe via fn, step B" */ pipeVia(fn) { this.pendingPipe = fn; return this; } build() { if (this.pendingPipe !== undefined) { throw new Error('Sequence.build(): .pipeVia() called with no following .step() to consume it'); } return new Sequence(this.opts, this.steps); } } //# sourceMappingURL=Sequence.js.map