Summary
When an agent returns a binary payload ({ buffer: Buffer }), debugResultKey walks it as a plain object and emits one debug key string per byte. Those keys are stored on TransactionLog.resultKeys and retained until the graph finishes, so a single 4MB buffer costs ~1.25GB of heap. In a mapAgent over N rows this scales with N and OOMs the process.
Found while chasing a memory-overflow report in mulmocast-cli: rendering a video grew by ~115MB per beat, and every beat's TTS mp3 / generated PNG is only a few hundred KB.
Reproduction
graphai 2.0.18, Node v22.18.0:
import { GraphAI } from "graphai";
const SIZE = 4 * 1024 * 1024; // 4MB, e.g. a generated image or a short mp4
const bigAgent = async () => ({ buffer: Buffer.alloc(SIZE, 1) });
const agents = { bigAgent: { agent: bigAgent, mock: bigAgent, name: "bigAgent", samples: [], description: "", category: [], author: "", repository: "", license: "MIT" } };
const before = process.memoryUsage();
const graph = new GraphAI({ version: 0.5, nodes: { gen: { agent: "bigAgent", inputs: {}, isResult: true } } }, agents);
await graph.run();
const after = process.memoryUsage();
const mb = (n) => Math.round(n / 1024 / 1024);
console.log("buffer size:", mb(SIZE), "MB");
console.log("heap+external delta:", mb(after.heapUsed + after.external - before.heapUsed - before.external), "MB");
console.log("resultKeys length:", graph.transactionLogs().find((l) => l.nodeId === "gen" && l.resultKeys)?.resultKeys.length);
Output:
buffer size: 4 MB
heap+external delta: 1253 MB
resultKeys length: 4194306
One key per byte — ":gen.buffer.0" … ":gen.buffer.4194303" — for a ~280x expansion of the payload.
Cause
src/utils/utils.ts, debugResultKeyInner:
if (Array.isArray(result)) {
...
}
return Object.keys(result).reduce((tmp, key) => {
tmp[key] = debugResultKeyInner(result[key]);
return tmp;
}, {});
A Buffer/Uint8Array is array-like but Array.isArray is false, so it falls through to the Object.keys() branch, and Object.keys on a typed array returns every index. objectToKeyArray then materializes an array and a joined string for each one.
The keys are attached in TransactionLog.onComplete (this.resultKeys = debugResultKey(...)), and the log lives for the duration of the run, so the expansion is retained rather than transient. Under mapAgent every row pays it, which is where the growth-with-N comes from.
Suggested fix
Skip binary views — there are no meaningful sub-keys to report for them:
const debugResultKeyInner = (result: ResultData): Record<string, unknown> => {
if (result === null || result === undefined) return {};
if (typeof result === "string") return {};
if (ArrayBuffer.isView(result) || result instanceof ArrayBuffer) return {};
...
};
Verified against the repro above (patched in node_modules): heap+external delta goes from 1253MB to 4MB, and the node result itself is unaffected.
It may also be worth bounding the key walk generally (depth and/or total key count), since any large plain array hits the same shape — a 1M-element array of numbers produces 1M keys too.
Summary
When an agent returns a binary payload (
{ buffer: Buffer }),debugResultKeywalks it as a plain object and emits one debug key string per byte. Those keys are stored onTransactionLog.resultKeysand retained until the graph finishes, so a single 4MB buffer costs ~1.25GB of heap. In amapAgentover N rows this scales with N and OOMs the process.Found while chasing a memory-overflow report in mulmocast-cli: rendering a video grew by ~115MB per beat, and every beat's TTS mp3 / generated PNG is only a few hundred KB.
Reproduction
graphai 2.0.18, Node v22.18.0:
Output:
One key per byte —
":gen.buffer.0"…":gen.buffer.4194303"— for a ~280x expansion of the payload.Cause
src/utils/utils.ts,debugResultKeyInner:A
Buffer/Uint8Arrayis array-like butArray.isArrayis false, so it falls through to theObject.keys()branch, andObject.keyson a typed array returns every index.objectToKeyArraythen materializes an array and a joined string for each one.The keys are attached in
TransactionLog.onComplete(this.resultKeys = debugResultKey(...)), and the log lives for the duration of the run, so the expansion is retained rather than transient. UndermapAgentevery row pays it, which is where the growth-with-N comes from.Suggested fix
Skip binary views — there are no meaningful sub-keys to report for them:
Verified against the repro above (patched in
node_modules): heap+external delta goes from 1253MB to 4MB, and the node result itself is unaffected.It may also be worth bounding the key walk generally (depth and/or total key count), since any large plain array hits the same shape — a 1M-element array of numbers produces 1M keys too.