agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
370 lines (369 loc) • 18.1 kB
TypeScript
/**
* Parallel — fan-out composition: N branches run concurrently, then merge.
*
* Pattern: Builder (GoF) + Adapter over footprintjs's `addSubFlowChart`
* (fork children).
* Role: core-flow/ layer. All branches receive the same input
* `{ message }`. Each returns a string. A merge step combines
* them: either a pure function OR an LLM.
* Emits: agentfootprint.composition.enter / exit +
* composition.fork_start / branch_complete / merge_end
* (via compositionRecorder).
*/
import { type FlowchartCheckpoint, type StructureRecorder } from 'footprintjs';
import type { RunOptions } from '../core/runner.js';
import type { GroupMetadata, GroupTranslator } from '../core/translator.js';
import type { RunnerPauseOutcome } from '../core/pause.js';
import type { LLMProvider } from '../provider-contract/types.js';
import type { Runner } from '../core/runner.js';
import { RunnerBase } from '../core/RunnerBase.js';
import type { MemoryIdentity } from '../memory/identity/types.js';
export interface ParallelOptions {
readonly name?: string;
readonly id?: string;
/**
* Optional build-time recorders passed through to footprintjs's
* `flowChart()` factory. Each recorder observes per-node build
* events (`onStageAdded` / `onSubflowMounted` / etc.) for this
* composition's internal chart (Seed + each branch mount + Merge).
*
* Cascade: each branch runner attaches its OWN recorders at its
* own construction time. footprintjs does NOT propagate
* StructureRecorders into mounted subflows — so for full coverage,
* attach the same recorders to every nested composition. See the
* core-flow README's "StructureRecorder cascade" section.
*
* When omitted, no build-time observation is wired up.
*/
readonly structureRecorders?: readonly StructureRecorder[];
/**
* Optional per-COMPOSITION translator (UI-agnostic). When attached,
* `runner.getUIGroup()` invokes it with the Parallel's
* `GroupMetadata` (kind, id, name, branches list, merge strategy)
* and returns whatever shape the translator produces.
*
* Independent of `structureRecorders` — those observe per-node spec
* events, this shapes whole-composition UI groups. Common case is to
* thread the SAME `GroupTranslator` reference through every nested
* composition so `member.uiGroup` is populated recursively; L1c
* per-method overrides add finer control.
*
* When omitted, `getUIGroup()` returns `undefined`.
*/
readonly groupTranslator?: GroupTranslator;
}
export interface ParallelInput {
readonly message: string;
/**
* WHO this run is for — the same tuple as `AgentInput.identity`. Every
* member runs for it: its memory, its tools' `ctx.identity`. Omitted, every
* member runs under this run's own conversation (`{ conversationId: runId }`),
* never a namespace another run shares (`core/memberRun.ts`).
*/
readonly identity?: MemoryIdentity;
}
export type ParallelOutput = string;
type BranchChild = Runner<{
message: string;
}, string>;
export type MergeFn = (branchResults: Readonly<Record<string, string>>) => string;
/**
* Outcome per branch in tolerant mode. One of:
* - `{ ok: true, value: string }` — branch succeeded; `value` is the returned string
* - `{ ok: false, error: string }` — branch threw; `error` is the error message
*
* Consumers in tolerant mode receive `Record<branchId, BranchOutcome>` and
* decide how to handle partial failure (e.g., fall back to a default,
* log, retry, or surface a user-facing message).
*/
export type BranchOutcome = {
readonly ok: true;
readonly value: string;
} | {
readonly ok: false;
readonly error: string;
};
export type MergeOutcomesFn = (outcomes: Readonly<Record<string, BranchOutcome>>) => string;
export interface MergeWithLLMOptions {
readonly provider: LLMProvider;
readonly model: string;
/** Prompt prepended to the branch results when feeding the merge LLM. */
readonly prompt: string;
readonly temperature?: number;
readonly maxTokens?: number;
}
interface BranchEntry {
readonly id: string;
readonly name: string;
readonly runner: BranchChild;
/**
* Optional per-method translator override for THIS branch only.
* When set, the branch's `member.uiGroup` is produced by invoking
* this translator against the runner's own `GroupMetadata`, instead
* of calling `branch.runner.getUIGroup()` (which would use the
* runner's own constructor-level translator).
*/
readonly groupTranslator?: GroupTranslator;
/** This branch's failure rejects the whole run. See `ParallelBranchOptions.required`. */
readonly required?: boolean;
}
/**
* Options bag accepted by `ParallelBuilder.branch()` for per-method
* overrides. Backwards-compatible with the legacy
* `.branch(id, runner, name?)` signature: when the third arg is a
* string it's still treated as `name`.
*/
export interface ParallelBranchOptions {
/** Human-friendly name for this branch. Default: the branch id. */
readonly name?: string;
/** Per-method translator override. See `BranchEntry.groupTranslator`. */
readonly groupTranslator?: GroupTranslator;
/**
* Mark this branch as REQUIRED: its failure rejects the whole Parallel
* run — even under a tolerant `.mergeOutcomesWithFn()` merge — with an
* error that names the branch. Default `false` (existing semantics:
* strict merges aggregate failures at the join; tolerant merges receive
* them as `BranchOutcome` entries).
*
* Fail-fast wiring: when EVERY branch is required, footprintjs's
* fork-level `failFast` is engaged (`Promise.all`) so the first failure
* aborts the fan-out immediately — siblings are not awaited and the
* Merge stage never runs. When only SOME branches are required, the
* fan-out stays best-effort (`Promise.allSettled`) and required
* failures are enforced at the Merge join instead — footprintjs's
* `failFast` is all-or-nothing per fork node, so engaging it for a
* mixed set would wrongly abort the run when an OPTIONAL sibling
* throws. See https://agentfootprint.dev/docs/getting-started/key-concepts/ (Parallel).
*
* Pause semantics under fail-fast: with every branch required, a branch
* that PAUSES (`pauseHere()`) pre-empts its siblings the same way a
* failure does — `Promise.all` settles on the first non-success, so
* still-running siblings are not awaited before the run surfaces the
* `RunnerPauseOutcome`. The checkpoint reflects the paused branch;
* `resume()` continues from there and re-attributes any post-resume
* required-branch failure just like `run()` does. Under the default
* best-effort fork, a pause is only surfaced after every sibling
* settles.
*
* Nested-mounting limitation: required-branch attribution and the
* synthetic `composition.exit` are wired through `Parallel.run()` /
* `Parallel.resume()`. When the Parallel's chart is instead MOUNTED
* into an outer composition (e.g. `Sequence.step('s', parallel)`), the
* outer runner's executor runs the chart — the fork-level `failFast`
* still aborts the fan-out, but the rejection surfaces RAW (no
* `required branch 'x' failed` wrapping) and the nested Parallel's
* `composition.enter` is left without a matching `exit`. See README
* Decision 8.
*/
readonly required?: boolean;
}
/**
* Per-branch first-error record captured during a run.
*
* `raw` is the ORIGINAL thrown value (footprintjs's
* `FlowErrorEvent.structuredError.raw`) — the identity key used by
* `rethrowWithBranchAttribution()` to correlate a fail-fast rejection back
* to its branch regardless of the error's class name. `message` is the
* BARE message (no `TypeError:` / `RateLimitError:` prefix) used in merge
* aggregates, tolerant `BranchOutcome.error` strings, and the attributed
* error text.
*
* @internal Exported only for `wrapBranchOutputMapper`'s signature — not
* part of the public API (not re-exported from the package barrel).
*/
export interface BranchErrorRecord {
readonly message: string;
readonly raw: unknown;
}
type MergeStrategy = {
readonly kind: 'fn';
readonly fn: MergeFn;
} | {
readonly kind: 'llm';
readonly opts: MergeWithLLMOptions;
} | {
readonly kind: 'outcomes-fn';
readonly fn: MergeOutcomesFn;
};
export declare class Parallel extends RunnerBase<ParallelInput, ParallelOutput> {
readonly name: string;
readonly id: string;
private readonly branches;
private readonly merge;
private readonly opts;
private currentRunContext;
/**
* Per-branch first-error records captured during the current run.
*
* Filled by an internal CombinedRecorder attached in `createExecutor()`
* that observes footprintjs `FlowErrorEvent`s. Errors are keyed by the
* branch id (the first segment of `traversalContext.subflowPath`); only
* the first error per branch is kept so the surface mirrors the wrapper
* `try/catch` semantics that preceded the v0.x architectural refactor.
*
* Read by the Merge stage to populate strict-mode error messages and
* tolerant-mode `BranchOutcome.error` strings, and by
* `rethrowWithBranchAttribution()` to correlate fail-fast rejections by
* error IDENTITY (`record.raw`). Cleared at the start of every `run()` /
* `resume()`; writes are epoch-guarded (see `runEpoch`) so abandoned
* fail-fast stragglers from a dead run cannot contaminate the live one.
*/
private readonly branchErrors;
/**
* Monotonic run token — incremented at every `createExecutor()` (i.e.
* each `run()` / `resume()`). The branch-error recorder captures the
* epoch current at attach time and only writes while it is STILL
* current. Under fail-fast, abandoned siblings of a rejected run keep
* executing in the background; without this guard a late failure from
* run N would land in run N+1's `branchErrors` (first-error-wins would
* then block run N+1's real error).
*/
private runEpoch;
/** Ids of branches declared `{ required: true }`. See `ParallelBranchOptions.required`. */
private readonly requiredIds;
/**
* True when EVERY branch is required — the fork node carries
* footprintjs's `failFast` flag, so the first branch failure rejects
* `executor.run()` with the RAW branch error before the Merge stage
* can attribute it. `run()`/`resume()` re-attribute via
* `rethrowWithBranchAttribution()`.
*/
private readonly failFastEngaged;
constructor(opts: ParallelOptions, branches: readonly BranchEntry[], merge: MergeStrategy);
static create(opts?: ParallelOptions): ParallelBuilder;
protected getGroupTranslator(): GroupTranslator | undefined;
/**
* Build the Parallel's `GroupMetadata` — kind `'Parallel'`, with one
* `GroupMember` per branch. Each member exposes its `runner` plus
* the runner's own `getUIGroup()` output (when the consumer
* threaded the same translator through that branch's construction).
*/
protected buildUIGroupMetadata(): GroupMetadata;
run(input: ParallelInput | string, options?: RunOptions): Promise<ParallelOutput | RunnerPauseOutcome>;
resume(checkpoint: FlowchartCheckpoint, input?: unknown, options?: RunOptions): Promise<ParallelOutput | RunnerPauseOutcome>;
/**
* Re-attribute a fail-fast abort to its originating branch.
*
* When `failFastEngaged` (every branch required), a failing branch
* rejects `executor.run()` with the RAW branch error — the Merge stage,
* which normally attributes failures by branch id, never runs. The
* internal `parallel-branch-errors` recorder DID see the failure
* (FlowRecorder.onError fires before the fork rejects), so correlate
* the rejection against that map and wrap it in a branch-naming error.
* Also emit the `composition.exit` (status `'err'`) the aborted Merge
* stage could not, preserving enter/exit pairing for dashboards.
*
* Correlation is by error IDENTITY first (`record.raw === err` — the
* recorder stores the ORIGINAL error object from
* `FlowErrorEvent.structuredError.raw`), with bare-message equality as
* a fallback for throws whose identity doesn't survive an engine
* boundary (e.g. non-Error values). Identity matching is what makes
* attribution work for ANY named Error subclass — `TypeError`, provider
* SDK errors like `RateLimitError`, etc. — where message-only matching
* would silently fail against name-prefixed strings.
*
* Rejections that don't correlate (engine errors, merge-stage errors,
* non-fail-fast runs) are rethrown untouched.
*
* Only engaged on the `run()` / `resume()` path — a Parallel chart
* MOUNTED into an outer composition rejects raw (see
* `ParallelBranchOptions.required` JSDoc, "Nested-mounting limitation").
*/
private rethrowWithBranchAttribution;
/**
* Find the branch whose recorded first error corresponds to `err`:
* identity match on the original thrown value first, bare-message
* equality second. Returns `undefined` when nothing correlates.
*/
private correlateBranchError;
/**
* Synthetic `composition.exit` for fail-fast aborts (no stage scope to
* emit from). Meta is built from the SAME run context the paired
* `composition.enter` used (`buildEventMeta` + `currentRunContext`), so
* the pair shares one real `runId` — Convention 4 run-scoping. Only the
* `runtimeStageId` degrades (`'unknown#0'`): the abort happens outside
* any stage, so there is no stage origin to attach.
*/
private emitFailFastAbortExit;
private createExecutor;
/**
* Build the internal recorder that captures first-error-per-branch.
*
* footprintjs's `SubflowExecutor` swallows subflow errors into
* `parentContext.debug.addError(...)` and skips the `outputMapper`,
* so the failed branch's error message never lands in parent scope.
* To preserve the per-branch error surface the wrapper-based design
* provided, we observe `FlowRecorder.onError` and correlate by the
* first segment of `traversalContext.subflowPath`.
*
* Only the FIRST error per branch is kept. Errors fired outside any
* branch (e.g., a Merge-stage error) are ignored.
*
* `epoch` is the run token current at attach time. Under fail-fast, a
* rejected run's abandoned siblings keep executing in the background —
* THIS recorder (attached to that dead run's executor) may still
* receive their late `onError` events while a NEW run is live. The
* epoch check drops those writes so the live run's map stays clean.
*/
private makeBranchErrorRecorder;
private finalizeResult;
private buildChart;
}
/**
* Wrap a branch's `outputMapper` so a mapper throw is ATTRIBUTED to its
* branch before footprintjs swallows it.
*
* footprintjs's `SubflowExecutor` catches outputMapper errors into
* `parentContext.addError('outputMapperError', ...)` WITHOUT firing
* `FlowRecorder.onError` — so Parallel's internal `parallel-branch-errors`
* recorder never sees them, and the Merge stage would fall back to
* `'unknown error'` for the missing branch id. Recording into the
* branch-error map here (first error per branch wins, mirroring the
* recorder's semantics) closes that attribution gap. The error is then
* RETHROWN so footprintjs's existing bookkeeping stays untouched: the
* `outputMapperError` debug entry is still written and the branch id is
* still absent from `branchResults` (which is how the Merge stage detects
* the failure).
*
* @internal Exported for direct unit testing — not part of the public API
* (not re-exported from the package barrel).
*/
export declare function wrapBranchOutputMapper(branchId: string, branchErrors: Map<string, BranchErrorRecord>, inner: (sfOutput: unknown) => Record<string, unknown>): (sfOutput: unknown) => Record<string, unknown>;
/** Fluent builder. Requires at least 2 branches + one merge strategy. */
export declare class ParallelBuilder {
private readonly opts;
private readonly branches;
private merge;
private readonly seenIds;
constructor(opts: ParallelOptions);
/**
* Add a branch. All branches run concurrently with the same input.
*
* Third arg accepts EITHER a legacy bare `name` string (back-compat
* with pre-L1c callers) OR a `ParallelBranchOptions` bag containing
* `name` and/or a per-method `groupTranslator` override. The
* override applies ONLY to this branch's `member.uiGroup` and does
* not affect any other branch or the runner's own translator.
*/
branch(id: string, runner: BranchChild, nameOrOpts?: string | ParallelBranchOptions): this;
/**
* Merge branch results via a pure function.
* `fn` receives `{ [branchId]: string }` and returns the merged string.
*/
mergeWithFn(fn: MergeFn): this;
/** Merge branch results by feeding them to an LLM for synthesis. */
mergeWithLLM(opts: MergeWithLLMOptions): this;
/**
* Tolerant merge — receives `{ [branchId]: BranchOutcome }` including
* both successes (`{ ok: true, value }`) and failures (`{ ok: false, error }`).
* Parallel does NOT throw on partial failure when this merge variant is
* used; the consumer's `fn` decides how to handle it (fall back, surface
* a warning, retry at a higher level, etc.).
*
* Use the default `mergeWithFn` / `mergeWithLLM` variants when you want
* a single failing branch to abort the whole Parallel loudly.
*/
mergeOutcomesWithFn(fn: MergeOutcomesFn): this;
build(): Parallel;
}
export {};