UNPKG

agentfootprint

Version:

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

275 lines • 14 kB
/** * workflow() — sequential steps whose hand-offs are checked by the compiler. * * WHY this exists: `Sequence` is the workhorse for "A, then B, then C", * and every step it accepts has the same shape — takes `{ message }`, * returns `string`. That is exactly right for chaining LLM calls, and * exactly wrong the moment a step wants to hand the next one something * structured: `Sequence` coerces any non-string step output to `''` * (Sequence.ts, the step `outputMapper`), so a step that returns a parsed * ticket silently hands the next step nothing at all. The mistake shows up * as an empty prompt three steps later, at runtime, in production. * * `workflow()` closes that gap from both ends: * * - **At compile time** — step N's OUTPUT type must be what step N+1 * accepts. A `Runner<{ message: string }, Ticket>` followed by a * `Runner<{ orderId: string }, string>` does not compile. The chain is * proven before you run it, not debugged after. * - **At run time** — a step's value is handed to the next step * UNCHANGED. Objects stay objects. The one convenience is the house * convention: a step that returns a `string` feeds the next step's * `{ message }`, because that is what every LLM runner here wants. * * Pattern: Adapter over footprintjs's `addSubFlowChartNext`, with the * type-level handoff proof carried by overloads (1–8 steps). * Role: core-flow/ layer, alongside Sequence/Parallel/Conditional/Loop. * Pure control flow — no LLM dependency. * Emits: agentfootprint.composition.enter / exit, reported as kind * `'Sequence'` — a workflow IS a sequential composition, and * widening the public `CompositionKind` union would break * exhaustive switches in consumer code for no behavioural gain. * * THREE HONEST LIMITS, all inherited from the engine and all verified in * `test/core-flow/scenario/Workflow.test.ts` — worth knowing before you * put rich objects on the wire: * * 1. Only PLAIN DATA crosses a step boundary. A value with a prototype * (Date, Map, Set, a class instance) arrives as `{}`, and `undefined` * fields are dropped. Send strings, numbers, arrays and plain * objects; send a timestamp as an ISO string, not a `Date`. * 2. A step must RETURN its output — the value handed forward is the * step chart's traversal result. A step whose last stage returns * nothing hands its whole scope forward instead. * 3. The workflow's own input keys stay visible to LATER steps too * (footprintjs's `getArgs()` inherits the run's arguments). A key the * previous step actually produced always wins; a key it did NOT * produce can still be read from the original input rather than * coming back `undefined`. * * @example a typed three-step chain * ```ts * interface Ticket { orderId: string; angry: boolean } * * const parse: Runner<{ message: string }, Ticket> = …; * const lookup: Runner<Ticket, { refundUsd: number }> = …; * const reply: Runner<{ refundUsd: number }, string> = …; * * const intake = workflow(parse, lookup, reply); * const answer = await intake.run({ message: 'where is my refund?' }); * // ^? string — the chain's last output type * * workflow(parse, reply); // ✗ compile error: Ticket is not { refundUsd } * ``` */ import { FlowChartExecutor, flowChart, } from 'footprintjs'; import { composedInput, readsMessageFromIfAny } from '../core/messageFrom.js'; import { adoptMemberRedaction } from '../redaction/declared.js'; import { RunnerBase, makeRunId, underRunSignal } from '../core/RunnerBase.js'; import { agentRecorder } from '../recorders/core/AgentRecorder.js'; import { compositionRecorder } from '../recorders/core/CompositionRecorder.js'; import { ContextRecorder } from '../recorders/core/ContextRecorder.js'; import { streamRecorder } from '../recorders/core/StreamRecorder.js'; import { typedEmit } from '../recorders/core/typedEmit.js'; import { carryVerdict, childOutcome, raiseChildVerdict } from './childOutcome.js'; import { adoptParentRun, parentRunIn, readsParentRun, closeParentRun, openParentRun, reopenParentRun, withParentRun, } from '../core/memberRun.js'; import { requestOf } from '../core/requestOfRun.js'; /** * Hand the previous step's value to the next step as its input args. * * `string` → `{ message }` (the house convention). Plain object → itself. * Anything else is a broken hand-off and says so loudly: the alternative * is an empty input three steps downstream with nothing pointing back * here. */ function toStepArgs(value, stepNumber) { if (typeof value === 'string') return { message: value }; if (typeof value === 'object' && value !== null && !Array.isArray(value)) { return { ...value }; } const got = value === null ? 'null' : Array.isArray(value) ? 'an array' : typeof value; throw new Error(`workflow: step ${stepNumber - 1} handed forward ${got}, but step ${stepNumber} needs an ` + 'object (or a string, which arrives as { message }). Make each step return its output.'); } /** * A sequential composition that passes values through untouched. Build one * with {@link workflow} — that factory carries the type-level chain proof. */ export class Workflow extends RunnerBase { name; id; steps; opts; currentRunContext = { runStartMs: 0, runId: 'pending', compositionPath: [], }; constructor(steps, opts = {}) { super(); if (steps.length === 0) { throw new Error('Workflow: must have at least one step'); } this.opts = opts; this.name = opts.name ?? 'Workflow'; this.id = opts.id ?? 'workflow'; this.steps = steps; // Holding a step that reads `messageFrom`, it reads it too (`core/messageFrom.ts`). readsMessageFromIfAny(this, steps); // A step's redaction policy covers this composition's run — every step // runs on its executor (`src/redaction/declared.ts`). adoptMemberRedaction(this, steps); // 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()); } async run(input, options) { // The incoming request, refused here — before anything moves — when // requestContext() did not build it. const request = requestOf(options, 'Workflow.run'); const executor = this.createExecutor(); this.lastExecutor = executor; // The run every step serves — the caller's identity when the input names // one (`input.identity`, as `AgentInput` does), else this run's own // conversation (`core/memberRun.ts`). const parentRun = openParentRun(this.currentRunContext.runId, input?.identity, 'Workflow.run', request); try { const result = await underRunSignal(options, (engine) => executor.run({ input: { ...input, 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, 'Workflow.resume'); const parentRun = reopenParentRun(checkpoint.sharedState, 'Workflow.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: [`Workflow:${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. const redaction = this.openRunRedaction(resumeLeg, getRunCtx); 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; return result; } buildChart() { const steps = this.steps; const compositionId = this.id; const compositionName = this.name; const seed = (scope) => { // The workflow's own input IS step 1's input — no unwrapping, no // re-wrapping; that is the whole point of the typed chain. The run it // serves is kept apart and handed to EVERY step (`core/memberRun.ts`). const { parentRun: _run, ...args } = scope.$getArgs(); adoptParentRun(scope, { parentRun: _run }, 'Workflow'); scope.current = args; typedEmit(scope, 'agentfootprint.composition.enter', { kind: 'Sequence', id: compositionId, name: compositionName, childCount: steps.length, }); }; // Root description prefix `Sequence:` is the taxonomy marker every // consumer (Lens, FlowchartRecorder.mapTopologyToSteps) already reads. let builder = flowChart('Seed', seed, 'seed', { ...(this.opts.structureRecorders !== undefined && { structureRecorders: [...this.opts.structureRecorders], }), description: `Sequence: ${steps.length}-step typed workflow`, }); steps.forEach((step, index) => { const stepNumber = index + 1; builder = builder.addSubFlowChartNext(`step-${stepNumber}`, step.getSpec(), `Step ${stepNumber}`, { // Step 1 is handed the workflow's own input as it came (a composed // mark handed to the workflow included). Every later step 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 (`childOutcome.ts`). raiseChildVerdict((key) => parent[key]); const args = toStepArgs(parent.current, stepNumber); const mapped = stepNumber === 1 ? args : composedInput(step, args); return withParentRun(mapped, parentRunIn(parent), step); }, // Untouched: whatever the step's chart returned is what the next // step (or the caller) receives. No string coercion. // The child reads its own outcome (`childOutcome.ts`): its answer is // handed on (an agent's validated answer as its text, never the // object it leaves as), and a verdict is carried onto the // workflow's state — never the failed child's state itself. outputMapper: (sfOutput) => { const outcome = childOutcome(step, sfOutput); return 'verdict' in outcome ? carryVerdict(outcome.verdict) : { current: outcome.answer }; }, }); }); 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)); typedEmit(scope, 'agentfootprint.composition.exit', { kind: 'Sequence', id: compositionId, name: compositionName, status: 'ok', durationMs: Date.now() - this.currentRunContext.runStartMs, }); return scope.current; }, 'finalize', 'Workflow finalize'); return builder.build(); } } export function workflow(...steps) { if (steps.length === 0) { throw new Error('workflow(): needs at least one step'); } return new Workflow(steps); } //# sourceMappingURL=Workflow.js.map