UNPKG

agentfootprint

Version:

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

771 lines • 38.1 kB
/** * 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 { assertUnreservedSubflowSegment } from '../lib/conventions.js'; import { FlowChartExecutor, flowChart, isFlowEvent, } from 'footprintjs'; import { callUnder } from '../lib/abort.js'; import { RunnerBase, makeRunId, underRunSignal } from '../core/RunnerBase.js'; import { normalizeRunInput } from '../core/runInput.js'; import { adoptParentRun, parentRunIn, readsParentRun, closeParentRun, openParentRun, reopenParentRun, withParentRun, } from '../core/memberRun.js'; import { requestOf } from '../core/requestOfRun.js'; import { buildEventMeta } from '../bridge/eventMeta.js'; import { ContextRecorder } from '../recorders/core/ContextRecorder.js'; import { streamRecorder } from '../recorders/core/StreamRecorder.js'; import { agentRecorder } from '../recorders/core/AgentRecorder.js'; import { compositionRecorder } from '../recorders/core/CompositionRecorder.js'; import { typedEmit } from '../recorders/core/typedEmit.js'; import { childError, childOutcome } from './childOutcome.js'; import { resilienceHooks } from '../recorders/core/resilienceHooks.js'; import { resilienceRecorder } from '../recorders/core/ResilienceRecorder.js'; import { composedInput, readsMessageFromIfAny } from '../core/messageFrom.js'; import { adoptMemberRedaction } from '../redaction/declared.js'; export class Parallel extends RunnerBase { name; id; branches; merge; opts; currentRunContext = { runStartMs: 0, runId: 'pending', compositionPath: [], }; /** * 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. */ branchErrors = new Map(); /** * 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). */ runEpoch = 0; /** Ids of branches declared `{ required: true }`. See `ParallelBranchOptions.required`. */ 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()`. */ failFastEngaged; constructor(opts, branches, merge) { super(); this.opts = opts; this.name = opts.name ?? 'Parallel'; this.id = opts.id ?? 'parallel'; if (branches.length < 2) { throw new Error('Parallel: must have at least 2 branches (use Sequence for a single runner)'); } this.branches = branches; // Holding a branch that reads `messageFrom`, it reads it too (`core/messageFrom.ts`). readsMessageFromIfAny(this, branches.map((b) => b.runner)); // A branch's redaction policy covers this composition's run — every branch // runs on its executor (`src/redaction/declared.ts`). adoptMemberRedaction(this, branches.map((b) => b.runner)); this.merge = merge; // Set BEFORE initChart — buildChart reads both fields. this.requiredIds = new Set(branches.filter((b) => b.required === true).map((b) => b.id)); this.failFastEngaged = branches.every((b) => b.required === true); // Eager chart construction — see `RunnerBase.initChart` JSDoc. // Safe: the merge stage's closure captures `this.branchErrors` // (an instance field initialized at declaration time, line 130), // It hands the run it serves to its members (`core/memberRun.ts`). readsParentRun(this); // which is set BEFORE the constructor body runs. this.initChart(() => this.buildChart()); } static create(opts = {}) { return new ParallelBuilder(opts); } // `getSpec()` inherited from RunnerBase — returns the cached chart. // ─── UI group translation (L1b) ─────────────────────────────── getGroupTranslator() { return this.opts.groupTranslator; } /** * 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). */ buildUIGroupMetadata() { const members = this.branches.map((b) => ({ memberId: b.id, runner: b.runner, // Per-method override (L1c) takes precedence over the branch // runner's own constructor-level translator. When present, the // override runs against the branch runner's own GroupMetadata // and its output becomes `member.uiGroup`. When absent, fall // back to `branch.runner.getUIGroup()` which applies the // runner's own translator (if any). uiGroup: b.groupTranslator !== undefined ? b.runner.getUIGroupWith(b.groupTranslator) : b.runner.getUIGroup(), })); return { kind: 'Parallel', id: this.id, name: this.name, members, extra: { mergeStrategy: this.merge.kind, }, }; } async run(input, options) { // The incoming request, refused here — before anything moves — when // requestContext() did not build it. const request = requestOf(options, 'Parallel.run'); const runInput = normalizeRunInput(input, 'Parallel.run'); const executor = this.createExecutor(); this.lastExecutor = executor; // The run every member serves — built once, here, from THIS run's id. const parentRun = openParentRun(this.currentRunContext.runId, runInput.identity, 'Parallel.run', request); try { let result; try { result = await underRunSignal(options, (engine) => executor.run({ input: { message: runInput.message, parentRun: parentRun.link }, ...(engine ?? {}), })); } catch (err) { this.rethrowWithBranchAttribution(err); } 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, 'Parallel.resume'); const parentRun = reopenParentRun(checkpoint.sharedState, 'Parallel.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; let result; try { result = await underRunSignal(options, (engine) => executor.resume(checkpoint, input, engine)); } catch (err) { this.rethrowWithBranchAttribution(err); } return this.finalizeResult(executor, result); } finally { closeParentRun(parentRun); } } /** * 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"). */ rethrowWithBranchAttribution(err) { if (this.failFastEngaged) { const match = this.correlateBranchError(err); if (match !== undefined) { const [branchId, recorded] = match; this.emitFailFastAbortExit(); throw new Error(`Parallel '${this.id}': required branch '${branchId}' failed: ${recorded.message}`, { cause: err, }); } } throw err; } /** * 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. */ correlateBranchError(err) { for (const entry of this.branchErrors) { if (entry[1].raw === err) return entry; } const message = err instanceof Error ? err.message : String(err); for (const entry of this.branchErrors) { if (entry[1].message === message) return entry; } return undefined; } /** * 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. */ emitFailFastAbortExit() { this.dispatcher.dispatch({ type: 'agentfootprint.composition.exit', payload: { kind: 'Parallel', id: this.id, name: this.name, status: 'err', durationMs: Date.now() - this.currentRunContext.runStartMs, }, meta: buildEventMeta(undefined, this.currentRunContext), }); } createExecutor(resumeLeg) { this.currentRunContext = { runStartMs: Date.now(), runId: makeRunId(), compositionPath: [`Parallel:${this.id}`], }; // Reset per-run branch-error capture. Fork children now mount the // branch runner's own chart (no try/catch wrapper); errors are // captured via FlowRecorder.onError correlation by subflow path. // The epoch bump invalidates recorders of any PREVIOUS run whose // abandoned fail-fast siblings are still executing — their late // errors must not land in this run's map. this.runEpoch += 1; this.branchErrors.clear(); // 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 branch declared. const redaction = this.openRunRedaction(resumeLeg, getRunCtx); // Reuse the cached chart built at constructor time. 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 })); // Provider-decorator telemetry from the `mergeWithLLM` helper — the // one place a Parallel calls a provider directly. executor.attachCombinedRecorder(resilienceRecorder({ dispatcher, getRunContext: getRunCtx })); executor.attachCombinedRecorder(this.makeBranchErrorRecorder(this.runEpoch)); for (const r of this.attachedRecorders) executor.attachCombinedRecorder(r); return executor; } /** * 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. */ makeBranchErrorRecorder(epoch) { const branchIds = new Set(this.branches.map((b) => b.id)); return { id: 'parallel-branch-errors', onError: (event) => { if (epoch !== this.runEpoch) return; // straggler from a dead run if (!isFlowEvent(event)) return; const ctx = event.traversalContext; if (!ctx) return; // The branch id is the first segment of the engine-prefixed // `stageId` (e.g. `bad/call-llm` → branch `bad`). `subflowPath` // is sometimes empty for first-level subflows, and `subflowId` // can be deeper than the branch when LLMCall/Agent mount their // own internal subflows. The stageId prefix is the canonical // origin path. const stageId = ctx.stageId ?? ''; const slash = stageId.indexOf('/'); const branchId = slash >= 0 ? stageId.slice(0, slash) : undefined; if (branchId === undefined || !branchIds.has(branchId)) return; if (!this.branchErrors.has(branchId)) { // `structuredError` preserves both the BARE message (no // `TypeError:` / `RateLimitError:` name prefix — consumers // expect the original `throw new X(msg)` reason exactly) and // the ORIGINAL error object (`raw`) used for identity-based // fail-fast re-attribution. Fall back to the flat // `event.message` (stripping the standard `Error: ` prefix) // only if a future footprintjs shape omits `structuredError`. const structured = event.structuredError; const message = structured?.message ?? (event.message.startsWith('Error: ') ? event.message.slice('Error: '.length) : event.message); this.branchErrors.set(branchId, { message, raw: structured?.raw }); } }, }; } finalizeResult(executor, result) { const paused = this.detectPause(executor, result); if (paused) return paused; if (result instanceof Error) throw result; if (typeof result === 'string') return result; throw new Error('Parallel: unexpected result shape — expected string'); } buildChart() { const branches = this.branches; const merge = this.merge; const compositionId = this.id; const compositionName = this.name; const seed = (scope) => { const args = scope.$getArgs(); adoptParentRun(scope, args, 'Parallel'); scope.userMessage = args.message; scope.branchResults = {}; typedEmit(scope, 'agentfootprint.composition.enter', { kind: 'Parallel', id: compositionId, name: compositionName, childCount: branches.length, }); typedEmit(scope, 'agentfootprint.composition.fork_start', { parentId: compositionId, branches: branches.map((b) => ({ id: b.id, name: b.name })), }); }; // Root description prefix `Parallel:` is the taxonomy marker — see // FlowchartRecorder.mapTopologyToSteps for the consumer side. // The 4th arg threads the consumer's `structureRecorders` (when set) // into footprintjs's builder so every node in this chart is observed // by them at construction time. let builder = flowChart('Initialize', seed, 'seed', { ...(this.opts.structureRecorders !== undefined && { structureRecorders: [...this.opts.structureRecorders], }), description: `Parallel: ${branches.length}-way fanout`, }); // Fork children — each branch is mounted as a proper subflow via // footprintjs's native fork mode. Multiple `addSubFlowChart` calls // on the same builder cursor produce a fork node (`type: 'fork'`); // `ChildrenExecutor` runs them concurrently via `Promise.allSettled`. // // The branch's OWN chart (its `getSpec()`) is mounted directly — no // wrapper. This preserves the parent's `runtimeStageId` address // space across branch internals: every LLM call, tool execution, // and commit inside a branch appears in the parent executor's // commitLog with a globally-unique id. Recorder events flow // naturally — no `scope.$emit` forwarding needed. // // Error handling: a failing branch's `outputMapper` does NOT fire // (footprintjs convention: `subflowError` skips output mapping). // The branch id is absent from `branchResults`. The merge stage // detects this via `branches.map(b => b.id) \ keys(branchResults)` // and either throws (strict mode) or synthesizes `BranchOutcome` // entries for the tolerant `outcomes-fn` merge. // // The outputMapper itself is wrapped (`wrapBranchOutputMapper`) so a // MAPPER throw is also attributed to its branch — see the helper's // JSDoc for why footprintjs can't attribute that class on its own. for (const branch of branches) { builder = builder.addSubFlowChart(branch.id, branch.runner.getSpec(), branch.name, { inputMapper: (parent) => { // The caller's own message — composed only when this composition was // handed a composed one (`core/messageFrom.ts`). const input = { message: parent.userMessage ?? '' }; const mapped = parent.messageFrom === 'composed' ? composedInput(branch.runner, input) : input; // Every branch runs for this run (`core/memberRun.ts`). return withParentRun(mapped, parentRunIn(parent), branch.runner); }, // The child reads its own outcome (`childOutcome.ts`). A branch that // ended on a verdict is a FAILED branch: its mapper throws the child's // own error, which the wrapper attributes. outputMapper: wrapBranchOutputMapper(branch.id, this.branchErrors, (sfOutput) => { const outcome = childOutcome(branch.runner, sfOutput); if ('verdict' in outcome) throw childError(outcome.verdict); return { branchResults: { [branch.id]: typeof outcome.answer === 'string' ? outcome.answer : '', }, }; }), }); } // Merge stage — runs after all fork children complete (join point). // Closes over `this.branchErrors` (populated by the internal // `parallel-branch-errors` recorder) so per-branch error messages // survive subflow boundary swallowing in footprintjs. const branchErrors = this.branchErrors; const requiredIds = this.requiredIds; const mergeStage = async (scope) => { const results = scope.branchResults ?? {}; // Detect failures by absence: any expected branch id missing // from `branchResults` is a branch whose `outputMapper` didn't // fire — i.e., one whose subflow errored. The specific error // message comes from the recorder-captured `branchErrors` map. // // footprintjs does NOT fire `FlowRecorder.onError` for errors // thrown INSIDE `applyOutputMapping` (those are caught separately // in `SubflowExecutor` and routed to // `parentContext.addError('outputMapperError', ...)`), so the // recorder alone would miss mapper-class failures. The // `wrapBranchOutputMapper` wrapper on every branch mount records // those into `branchErrors` before rethrowing, so both strict-mode // aggregation and tolerant-mode `BranchOutcome.error` print the // real message instead of `unknown error`. const failedIds = branches.map((b) => b.id).filter((id) => !(id in results)); const isTolerant = merge.kind === 'outcomes-fn'; const failedRequired = failedIds.filter((id) => requiredIds.has(id)); if (failedIds.length > 0 && (!isTolerant || failedRequired.length > 0)) { typedEmit(scope, 'agentfootprint.composition.exit', { kind: 'Parallel', id: compositionId, name: compositionName, status: 'err', durationMs: Date.now() - this.currentRunContext.runStartMs, }); // A REQUIRED branch failed under a tolerant merge: required // overrides tolerance — reject the run, naming the branch(es). // (Strict merges below already reject on ANY failure, required // or not, with the pre-existing aggregate message.) if (isTolerant) { const details = failedRequired .map((id) => ` ${id}: ${branchErrors.get(id)?.message ?? 'unknown error'}`) .join('\n'); throw new Error(`Parallel '${compositionId}': ${failedRequired.length} required branch(es) failed:\n${details}`); } const details = failedIds .map((id) => ` ${id}: ${branchErrors.get(id)?.message ?? 'unknown error'}`) .join('\n'); throw new Error(`Parallel '${compositionId}': ${failedIds.length} branch(es) failed:\n${details}\n` + `(use .mergeOutcomesWithFn() for tolerant-mode partial-failure handling)`); } // Run the merge strategy. Wrap in try/catch so that a merge-stage // failure (e.g., merge LLM throws) still produces a // `composition.exit` event with `status: 'err'` — without this the // event stream would have a `composition.enter` without a matching // `composition.exit`, breaking dashboards that pair the two. let merged; try { if (merge.kind === 'fn') { merged = merge.fn(results); } else if (merge.kind === 'llm') { merged = await mergeWithLLM(scope, merge.opts, results); } else { // Tolerant-mode: synthesize BranchOutcome map for the consumer. const outcomes = {}; for (const b of branches) { if (b.id in results) { outcomes[b.id] = { ok: true, value: results[b.id] }; } else { outcomes[b.id] = { ok: false, error: branchErrors.get(b.id)?.message ?? 'unknown error', }; } } merged = merge.fn(outcomes); } } catch (err) { typedEmit(scope, 'agentfootprint.composition.exit', { kind: 'Parallel', id: compositionId, name: compositionName, status: 'err', durationMs: Date.now() - this.currentRunContext.runStartMs, }); throw err; } const succeededCount = Object.keys(results).length; typedEmit(scope, 'agentfootprint.composition.merge_end', { parentId: compositionId, // Emit the merge.kind verbatim so consumers can distinguish // tolerant (`outcomes-fn`) from strict (`fn` / `llm`). strategy: merge.kind, resultSummary: truncate(merged, 80), // Number of branches that actually contributed a result to the // merge. Equals `totalBranchCount` on full success; smaller in // tolerant-mode runs where some branches failed. mergedBranchCount: succeededCount, totalBranchCount: branches.length, }); typedEmit(scope, 'agentfootprint.composition.exit', { kind: 'Parallel', id: compositionId, name: compositionName, status: 'ok', durationMs: Date.now() - this.currentRunContext.runStartMs, }); return merged; }; builder = builder.addFunction('Merge', mergeStage, 'merge', 'Parallel merge'); const chart = builder.build(); // Required-branch fail-fast: when EVERY branch is required, engage // footprintjs's fork-level `failFast` (`Promise.all` — first branch // error rejects the whole run, aborting before the Merge join). // // The fan-out node here is the SEED node — stacked `addSubFlowChart` // calls fork from the builder cursor, which is the chart root. // footprintjs only exposes a `failFast` option on its selector/list // builders, so for a plain stacked-subflow fork we stamp the public // `StageNode.failFast` field on the built chart's root directly // (the exact field `ChildrenExecutor.executeNodeChildren` reads). // // Deliberately NOT engaged for a MIXED required/optional set: // fork-level failFast is all-or-nothing, so an OPTIONAL sibling's // throw would also abort the run. Mixed sets are enforced at the // Merge join instead (see `mergeStage`). if (this.failFastEngaged) { chart.root.failFast = true; } return chart; } } /** * 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 function wrapBranchOutputMapper(branchId, branchErrors, inner) { return (sfOutput) => { try { return inner(sfOutput); } catch (err) { if (!branchErrors.has(branchId)) { branchErrors.set(branchId, { message: err instanceof Error ? err.message : String(err), raw: err, }); } throw err; } }; } /** Fluent builder. Requires at least 2 branches + one merge strategy. */ export class ParallelBuilder { opts; branches = []; merge; seenIds = new Set(); constructor(opts) { this.opts = opts; } /** * 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, runner, nameOrOpts) { if (this.seenIds.has(id)) { throw new Error(`Parallel.branch(): duplicate branch id '${id}'`); } // Branch errors are correlated back to the originating branch by the // first `/`-separated segment of footprintjs's engine-prefixed // `traversalContext.stageId` (e.g., `legal/call-llm` → branch // `legal`). A branch id containing `/` would silently shadow that // mapping and drop the error message into 'unknown error' fallback. if (id.includes('/')) { throw new Error(`Parallel.branch(): id '${id}' must not contain '/' — it collides with footprintjs's subflow-path separator used for per-branch error correlation`); } // The branch id is mounted VERBATIM as the subflow segment (see the // `addSubFlowChart(branch.id, …)` call in buildChart), so an `sf-` name // lands inside the framework's reserved namespace and is filtered out as // plumbing by every downstream reader instead of colliding loudly. assertUnreservedSubflowSegment('Parallel.branch()', 'branch id', id); this.seenIds.add(id); const opts = typeof nameOrOpts === 'string' ? ({ name: nameOrOpts }) : nameOrOpts ?? {}; const entry = { id, runner, name: opts.name ?? id, ...(opts.groupTranslator !== undefined && { groupTranslator: opts.groupTranslator, }), ...(opts.required !== undefined && { required: opts.required }), }; this.branches.push(entry); return this; } /** * Merge branch results via a pure function. * `fn` receives `{ [branchId]: string }` and returns the merged string. */ mergeWithFn(fn) { if (this.merge !== undefined) { throw new Error('Parallel: merge strategy already set'); } this.merge = { kind: 'fn', fn }; return this; } /** Merge branch results by feeding them to an LLM for synthesis. */ mergeWithLLM(opts) { if (this.merge !== undefined) { throw new Error('Parallel: merge strategy already set'); } this.merge = { kind: 'llm', opts }; return 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) { if (this.merge !== undefined) { throw new Error('Parallel: merge strategy already set'); } this.merge = { kind: 'outcomes-fn', fn }; return this; } build() { if (this.merge === undefined) { throw new Error('Parallel.build(): no merge strategy — call .mergeWithFn() or .mergeWithLLM() before build()'); } return new Parallel(this.opts, this.branches, this.merge); } } // ─── Helpers ────────────────────────────────────────────────────────── async function mergeWithLLM(scope, opts, results) { // XML-escape branch content to avoid prompt-injection via branch output. const sections = Object.entries(results) .map(([id, content]) => `<${id}>${xmlEscape(content)}</${id}>`) .join('\n'); const userContent = `${opts.prompt}\n\n${sections}`; const messages = [{ role: 'user', content: userContent }]; typedEmit(scope, 'agentfootprint.stream.llm_start', { iteration: 1, provider: opts.provider.name, model: opts.model, systemPromptChars: 0, messagesCount: 1, toolsCount: 0, ...(opts.temperature !== undefined && { temperature: opts.temperature }), }); const startMs = Date.now(); const request = { messages, model: opts.model, ...(opts.temperature !== undefined && { temperature: opts.temperature }), ...(opts.maxTokens !== undefined && { maxTokens: opts.maxTokens }), }; // The run's signal rides the request and bounds the wait; an answer that // lands after it is refused before `llm_end` could count it // (lib/abort.ts · callUnder). const response = await callUnder(request, scope.$getEnv?.()?.signal, (wired) => opts.provider.complete(wired, // Resilience-report channel: a decorated merge provider's fallback / // retry / recovery becomes an in-run typed event here. resilienceHooks(scope))); const durationMs = Date.now() - startMs; typedEmit(scope, 'agentfootprint.stream.llm_end', { iteration: 1, content: response.content, toolCallCount: response.toolCalls.length, usage: response.usage, stopReason: response.stopReason, durationMs, }); return response.content; } function xmlEscape(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); } function truncate(s, n) { if (s.length <= n) return s; return s.slice(0, n - 1) + '…'; } //# sourceMappingURL=Parallel.js.map