When one agent is not enough, you split the work. A supervisor hands sub-tasks to workers, and the workers hand results back. That handoff is where multi-agent systems live or die, because two things go wrong there and nowhere else: state leaks between agents that should stay isolated, and work gets done twice because nobody recorded that it was already finished.
Both failures are silent. The system still returns an answer, it is just one built on overwritten data, or on the same sub-task computed three times. This lesson builds the single thing that prevents both: an explicit, structured handoff that carries the task, the results gathered so far, and a ledger of what is already done. State moves between agents as data, never through shared globals, and every agent that receives it knows exactly how far the work has progressed.
flowchart LR S["supervisor"] -->|"state envelope"| A["worker A"] A -->|"adds a result,<br/>marks it done"| E["envelope v2"] E --> B["worker B"] B -->|"adds a result,<br/>marks it done"| E2["envelope v3"] style E fill:#8b5cf6,color:#fff style E2 fill:#8b5cf6,color:#fff
The handoff envelope
The object that travels between agents is the envelope. It has three fields. subtasks is the work still to consider. results is what has been produced so far, each entry an object with an id and a value. done is a ledger of sub-task identifiers that are already complete. The done ledger is the part beginners forget, and forgetting it is what causes duplicate work: without it, an agent cannot tell an unfinished sub-task from a finished one.
A worker does not edit the envelope it was handed. It reads it, does its piece, and returns a NEW envelope, with fresh arrays for results and done and the original left untouched. Returning a new object instead of mutating the shared one is what keeps agents from overwriting each other. The next worker receives the updated envelope, so it sees the new results and the ledger, while the original caller still holds its own unchanging copy.
function takeNext(state) {
const next = state.subtasks.find(s => !state.done.includes(s));
if (next === undefined) return state;
return {
subtasks: state.subtasks,
results: [...state.results, { id: next, value: 'r' + next }],
done: [...state.done, next]
};
}
const start = { subtasks: [1, 2, 3], results: [], done: [] };
const step1 = takeNext(start);
const step2 = takeNext(step1);
console.log(step1.done.join(',') + ' -> ' + step2.done.join(','));
console.log(step2.results.map(r => r.value).join(','));
console.log(start.done.length); // original untouched
Thread the state, do not broadcast it
The supervisor's job is to thread the envelope from one worker to the next, not to hand the SAME starting envelope to every worker. Threading means each worker's output is the next worker's input, a fold over the worker list. Broadcast the original instead, and every worker sees an empty done ledger, so every worker picks the first sub-task and you end up with three copies of result one and zero of the rest.
This is the single most common multi-agent bug: dispatching in parallel against a shared starting state and then concatenating the outputs. It looks efficient, and it duplicates every unit of work. Threading the envelope through, one worker at a time, is the fix, and it is also what makes a run deterministic and testable with no real model in sight.
flowchart TD BAD["shared mutable object"] --> X["agent A overwrites<br/>agent B loses its write"] GOOD["each agent returns<br/>a new envelope"] --> Y["no aliasing<br/>no lost updates"] style BAD fill:#b91c1c,color:#fff style GOOD fill:#166534,color:#fff
function takeNext(state) {
const next = state.subtasks.find(s => !state.done.includes(s));
if (next === undefined) return state;
return {
subtasks: state.subtasks,
results: [...state.results, { id: next, value: 'r' + next }],
done: [...state.done, next]
};
}
function runHandoff(workers, state) {
let s = state;
for (const w of workers) s = w(s);
return s;
}
const final = runHandoff([takeNext, takeNext, takeNext], { subtasks: [1, 2, 3], results: [], done: [] });
console.log('done: ' + final.done.join(','));
console.log('results: ' + final.results.map(r => r.value).join(','));
The ledger prevents duplicate work
When agents genuinely run in parallel and you must merge their envelopes afterwards, the done ledger earns its keep a second way. Two agents may both have completed sub-task 2, one because the supervisor asked it to, one because it inferred the task still needed it. Merging their result lists naively gives you two copies of result 2. Merging with the ledger deduplicates: walk every agent's results, keep the first entry for each identifier, and drop the rest.
Deduplication by identifier, preserving first-seen order, is the merge rule. It does not matter which agent finished first in wall-clock time; what matters is that each sub-task appears once in the final envelope, with exactly one result attached.
flowchart TD R["next result from an agent"] --> Q["id already seen?"] Q -->|"yes"| DROP["drop the duplicate"] Q -->|"no"| KEEP["keep it, record the id"] style DROP fill:#b91c1c,color:#fff style KEEP fill:#166534,color:#fff
function mergeResults(states) {
const results = [];
const done = [];
const seenR = new Set();
const seenD = new Set();
for (const st of states) {
for (const r of st.results) {
if (!seenR.has(r.id)) { seenR.add(r.id); results.push(r); }
}
for (const d of st.done) {
if (!seenD.has(d)) { seenD.add(d); done.push(d); }
}
}
return { results, done };
}
const agentA = { done: [1, 2], results: [{ id: 1, value: 'r1' }, { id: 2, value: 'r2' }] };
const agentB = { done: [2, 3], results: [{ id: 2, value: 'r2' }, { id: 3, value: 'r3' }] };
const merged = mergeResults([agentA, agentB]);
console.log('done: ' + merged.done.join(','));
console.log('count: ' + merged.results.length);
What does this print? takeNext returns a NEW envelope, so the original s is never mutated.
function takeNext(state) {
const next = state.subtasks.find(s => !state.done.includes(s));
if (next === undefined) return state;
return {
subtasks: state.subtasks,
results: [...state.results, { id: next, value: 'r' + next }],
done: [...state.done, next]
};
}
const s = { subtasks: [1, 2, 3], results: [], done: [] };
const after = takeNext(s);
console.log(after.done.join(','));
console.log(after.results[0].value);
console.log(s.done.length);
takeNext picks sub-task 1 (the first not in done) and returns a new envelope with done: [1].
The original s is never touched, so s.done is still empty.
Write runHandoff(workers, state) that threads the envelope through the list of workers: each worker receives the envelope the previous one returned, and the final envelope is returned. With zero workers, return the input state unchanged. The takeNext helper is already defined for you.
function takeNext(state) {
const next = state.subtasks.find(s => !state.done.includes(s));
if (next === undefined) return state;
return {
subtasks: state.subtasks,
results: [...state.results, { id: next, value: 'r' + next }],
done: [...state.done, next]
};
}
function runHandoff(workers, state) {
// thread the envelope: each worker's output is the next worker's input
}
Start from the input state and loop over the workers.
Reassign your running state to w(s) on each step, then return it.
function takeNext(state) {
const next = state.subtasks.find(s => !state.done.includes(s));
if (next === undefined) return state;
return {
subtasks: state.subtasks,
results: [...state.results, { id: next, value: 'r' + next }],
done: [...state.done, next]
};
}
function runHandoff(workers, state) {
let s = state;
for (const w of workers) s = w(s);
return s;
}
This dispatchAll is meant to run every worker and return the final envelope. But it hands the SAME starting state to every worker (via map) and then concatenates their outputs, so every worker sees an empty done ledger and they all redo the first sub-task. With three workers on sub-tasks [1, 2, 3] you get three copies of result 1 and none of the rest. Fix it to thread the envelope through the workers instead of broadcasting it.
function takeNext(state) {
const next = state.subtasks.find(s => !state.done.includes(s));
if (next === undefined) return state;
return {
subtasks: state.subtasks,
results: [...state.results, { id: next, value: 'r' + next }],
done: [...state.done, next]
};
}
function dispatchAll(workers, state) {
const outputs = workers.map(w => w(state));
return outputs.reduce((acc, o) => ({
subtasks: state.subtasks,
results: acc.results.concat(o.results),
done: acc.done.concat(o.done)
}), { subtasks: state.subtasks, results: [], done: [] });
}
map gives every worker the same starting state; you want each worker to see the previous one's output.
Replace the map-then-concat with a fold: start from state, and on each worker set s = w(s).
function takeNext(state) {
const next = state.subtasks.find(s => !state.done.includes(s));
if (next === undefined) return state;
return {
subtasks: state.subtasks,
results: [...state.results, { id: next, value: 'r' + next }],
done: [...state.done, next]
};
}
function dispatchAll(workers, state) {
let s = state;
for (const w of workers) s = w(s);
return s;
}
Write mergeResults(states) that folds several agents' envelopes into one, deduplicating by sub-task id. Each envelope has results (objects each with an id and value) and done (an array of ids). Keep the FIRST entry seen for each id, in the order you first see it; drop later duplicates. Do the same dedup for done. Return { results, done }.
function mergeResults(states) {
// dedupe results by id and done by value, first occurrence wins
}
Walk each envelope's results in order, tracking seen ids in a Set.
Push a result only the first time its id appears; do the same for done.
function mergeResults(states) {
const results = [];
const done = [];
const seenR = new Set();
const seenD = new Set();
for (const st of states) {
for (const r of st.results) {
if (!seenR.has(r.id)) { seenR.add(r.id); results.push(r); }
}
for (const d of st.done) {
if (!seenD.has(d)) { seenD.add(d); done.push(d); }
}
}
return { results, done };
}
Two workers are each handed the SAME starting envelope, and then their outputs are concatenated into the final result. What goes wrong?
Without threading, every worker starts from the same empty ledger, picks the same first sub-task, and the naive merge keeps all copies. Thread the envelope so each worker sees what the previous one already finished.
Recap
- State travels between agents as an explicit envelope:
subtasks,results, and adoneledger, never through hidden globals. - A worker treats its input as read-only and returns a new envelope with fresh arrays, so no two agents alias the same data.
- Thread the envelope from one worker to the next (a fold); broadcasting the same starting state and concatenating duplicates every unit of work.
- The done ledger prevents duplicate work across parallel agents: merge by identifier, keeping the first result for each id.
- Watch for
.push(on an incoming envelope, the signature of the mutation trap.
Next you will make these agents resilient: what should an agent do when one of its workers fails instead of finishing?