/** * 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 RunOptions, type StructureRecorder } from 'footprintjs'; import type { GroupMetadata, GroupTranslator } from '../core/translator.js'; import type { RunnerPauseOutcome } from '../core/pause.js'; import type { LLMProvider } from '../adapters/types.js'; import type { Runner } from '../core/runner.js'; import { RunnerBase } from '../core/RunnerBase.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; } export type ParallelOutput = string; type BranchChild = Runner<{ message: string; }, string>; export type MergeFn = (branchResults: Readonly>) => 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` 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>) => 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 `docs/guides/concepts.md` (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 { 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, options?: RunOptions): Promise; resume(checkpoint: FlowchartCheckpoint, input?: unknown, options?: RunOptions): Promise; /** * 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, inner: (sfOutput: unknown) => Record): (sfOutput: unknown) => Record; /** 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 {};