agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
280 lines • 14.6 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 }
* ```
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.workflow = exports.Workflow = void 0;
const footprintjs_1 = require("footprintjs");
const messageFrom_js_1 = require("../core/messageFrom.js");
const declared_js_1 = require("../redaction/declared.js");
const RunnerBase_js_1 = require("../core/RunnerBase.js");
const AgentRecorder_js_1 = require("../recorders/core/AgentRecorder.js");
const CompositionRecorder_js_1 = require("../recorders/core/CompositionRecorder.js");
const ContextRecorder_js_1 = require("../recorders/core/ContextRecorder.js");
const StreamRecorder_js_1 = require("../recorders/core/StreamRecorder.js");
const typedEmit_js_1 = require("../recorders/core/typedEmit.js");
const childOutcome_js_1 = require("./childOutcome.js");
const memberRun_js_1 = require("../core/memberRun.js");
const requestOfRun_js_1 = require("../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.
*/
class Workflow extends RunnerBase_js_1.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`).
(0, messageFrom_js_1.readsMessageFromIfAny)(this, steps);
// A step's redaction policy covers this composition's run — every step
// runs on its executor (`src/redaction/declared.ts`).
(0, declared_js_1.adoptMemberRedaction)(this, steps);
// 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());
}
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, '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 = (0, memberRun_js_1.openParentRun)(this.currentRunContext.runId, input?.identity, 'Workflow.run', request);
try {
const result = await (0, RunnerBase_js_1.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).
(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, 'Workflow.resume');
const parentRun = (0, memberRun_js_1.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 (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: [`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 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;
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();
(0, memberRun_js_1.adoptParentRun)(scope, { parentRun: _run }, 'Workflow');
scope.current = args;
(0, typedEmit_js_1.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 = (0, footprintjs_1.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`).
(0, childOutcome_js_1.raiseChildVerdict)((key) => parent[key]);
const args = toStepArgs(parent.current, stepNumber);
const mapped = stepNumber === 1 ? args : (0, messageFrom_js_1.composedInput)(step, args);
return (0, memberRun_js_1.withParentRun)(mapped, (0, memberRun_js_1.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 = (0, childOutcome_js_1.childOutcome)(step, sfOutput);
return 'verdict' in outcome
? (0, childOutcome_js_1.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.
(0, childOutcome_js_1.raiseChildVerdict)((key) => scope.$getValue(key));
(0, typedEmit_js_1.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();
}
}
exports.Workflow = Workflow;
function workflow(...steps) {
if (steps.length === 0) {
throw new Error('workflow(): needs at least one step');
}
return new Workflow(steps);
}
exports.workflow = workflow;
//# sourceMappingURL=Workflow.js.map