agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
177 lines • 7.73 kB
JavaScript
/**
* MapReduce — fan-out N LLMCalls over deterministic input shards, reduce.
*
* Origin: classic map-reduce (Dean & Ghemawat, 2004) applied to
* LLM context-window constraints. Seen in long-document
* summarization: split → summarize each → combine.
*
* Pattern: Factory → produces a Runner composing `Parallel` of N
* shard-aware branches. Each branch is an LLMCall wrapped so it
* extracts its shard index from the input by position.
* Role: patterns/ layer. Pure composition over existing primitives.
*
* Shard count is fixed at build time — consumer supplies `shardCount`
* and a `split(input, shardCount)` function that MUST return exactly
* `shardCount` strings. The extracted strings are packed into one
* `message` at run time (delimiter `\u001F`) so each branch can pick
* its own index. For run-time-variable shard counts, factor MapReduce
* inside a wrapping Runner that rebuilds the Parallel per call.
*/
import { flowChart, FlowChartExecutor } from 'footprintjs';
import { LLMCall } from '../core/LLMCall.js';
import { RunnerBase } from '../core/RunnerBase.js';
import { Parallel } from '../core-flow/Parallel.js';
import { Sequence } from '../core-flow/Sequence.js';
import { adoptMemberRedaction, redactionDeclaredBy } from '../redaction/declared.js';
import { adoptParentRun, parentRunIn, readsParentRun, withParentRun } from '../core/memberRun.js';
const SHARD_DELIMITER = '\u001F'; // ASCII Unit Separator — unlikely in real text.
/**
* Build a MapReduce Runner. At run time:
* 1. The splitter runs the consumer's `split(input, shardCount)` and
* packs the resulting N shards into a delimited string.
* 2. Parallel fans out to N branches. Each branch's wrapper extracts
* its own shard from the packed input and feeds it to the shared
* LLMCall.
* 3. The reducer combines the N branch outputs into the final string.
*/
export function mapReduce(opts) {
if (opts.shardCount < 2) {
throw new Error('MapReduce: shardCount must be >= 2 (use LLMCall for 1 shard)');
}
const shardCount = opts.shardCount;
// Step 1: split stage — takes the original input, runs split(), packs
// the shards into a delimited string. Exposed as a Runner so it can
// be the first step of a Sequence.
const splitRunner = new ShardSplitRunner(opts.split, shardCount);
// Step 2: per-branch wrappers + Parallel. Each branch's wrapper reads
// the packed message, splits on the delimiter, picks index i, and
// runs the shared LLMCall with just that shard.
let par = Parallel.create({
id: opts.id ? `${opts.id}-map` : 'mapreduce-map',
name: `${opts.name ?? 'MapReduce'}-Map`,
});
for (let i = 0; i < shardCount; i++) {
const branchRunner = new ShardBranchRunner(i, LLMCall.create({
provider: opts.provider,
model: opts.model,
...(opts.temperature !== undefined && { temperature: opts.temperature }),
...(opts.maxTokens !== undefined && { maxTokens: opts.maxTokens }),
})
.system(opts.mapPrompt)
.build());
par = par.branch(`shard-${i}`, branchRunner);
}
const mapRunner = opts.reduce.kind === 'fn'
? par.mergeWithFn(opts.reduce.fn).build()
: par.mergeWithLLM(opts.reduce.opts).build();
// Step 3: full pipeline — Sequence(split → Parallel+reduce).
return Sequence.create({
id: opts.id ?? 'mapreduce',
name: opts.name ?? 'MapReduce',
})
.step('split', splitRunner)
.step('map-reduce', mapRunner)
.build();
}
/**
* Runner that runs the consumer's `split()` and returns a packed string.
* Implemented as a thin Runner (rather than inline Sequence step) so it
* plugs into the existing Sequence.step API.
*/
class ShardSplitRunner extends RunnerBase {
name = 'ShardSplit';
id = 'shard-split';
splitFn;
shardCount;
constructor(splitFn, shardCount) {
super();
this.splitFn = splitFn;
this.shardCount = shardCount;
}
getSpec() {
const splitFn = this.splitFn;
const shardCount = this.shardCount;
return flowChart('Split', (scope) => {
const args = scope.$getArgs();
const shards = splitFn(args.message, shardCount);
const normalized = [];
for (let i = 0; i < shardCount; i++)
normalized.push(shards[i] ?? '');
scope.packed = normalized.join(SHARD_DELIMITER);
}, 'split')
.addFunction('Return', (scope) => scope.packed, 'return')
.build();
}
async run(input) {
const executor = new FlowChartExecutor(this.getSpec());
const result = await executor.run({ input: { message: input.message } });
if (typeof result === 'string')
return result;
throw new Error('ShardSplit: unexpected result shape');
}
async resume() {
throw new Error('ShardSplit does not support pause/resume');
}
}
/**
* Runner that extracts shard `i` from the packed message and runs the
* wrapped LLMCall with just that shard.
*/
class ShardBranchRunner extends RunnerBase {
name;
id;
shardIndex;
inner;
constructor(shardIndex, inner) {
super();
this.shardIndex = shardIndex;
this.inner = inner;
this.id = `shard-branch-${shardIndex}`;
this.name = `Shard ${shardIndex}`;
// The wrapped runner's redaction policy travels with the shard, so the
// `Parallel` that fans the shards out covers it (`src/redaction/declared.ts`).
adoptMemberRedaction(this, [inner]);
// It hands the run it serves on to the wrapped runner (`core/memberRun.ts`).
readsParentRun(this);
}
getSpec() {
// Build a wrapper chart that unpacks the shard and invokes the
// inner chart. The inner LLMCall's chart runs as a subflow whose
// input is the extracted shard.
const shardIndex = this.shardIndex;
const inner = this.inner;
const innerChart = inner.getSpec();
return flowChart('Unpack', (scope) => {
const args = scope.$getArgs();
const parts = (args.message ?? '').split(SHARD_DELIMITER);
scope.shard = parts[shardIndex] ?? '';
scope.result = '';
// The run the shard serves, handed on to the wrapped runner (`core/memberRun.ts`).
adoptParentRun(scope, args, 'mapReduce shard');
}, 'unpack')
.addSubFlowChartNext('inner', innerChart, 'InnerCall', {
inputMapper: (parent) => withParentRun({ message: parent.shard ?? '' }, parentRunIn(parent), inner),
outputMapper: (sfOutput) => ({
result: typeof sfOutput === 'string' ? sfOutput : '',
}),
})
.addFunction('Return', (scope) => scope.result, 'return')
.build();
}
async run(input) {
const executor = new FlowChartExecutor(this.getSpec());
// Run on its own (it never is inside `mapReduce`, which mounts it), it
// still keeps the wrapped runner's records under that runner's policy.
const policy = redactionDeclaredBy(this);
if (policy !== undefined)
executor.setRedactionPolicy(policy);
const result = await executor.run({ input: { message: input.message } });
if (typeof result === 'string')
return result;
throw new Error('ShardBranch: unexpected result shape');
}
async resume() {
throw new Error('ShardBranch does not support pause/resume');
}
}
//# sourceMappingURL=MapReduce.js.map