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