When Batching Your AI Agents Makes Them Read Each Other's Mail A developer discovered a critical bug in n8n's AI Agent node that causes silent data corruption when batch processing is enabled. The bug, found in the open-source workflow automation platform, leaks conversation history between items in the same batch, leading to incorrect AI agent responses. The developer traced the root cause to a shared metadata object in executeBatch.ts and implemented a fix by tagging tool-call data with item indices and filtering on retrieval. This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry. n8n https://github.com/n8n-io/n8n is an open-source workflow automation platform — think Zapier, but self-hostable, node-based, and built for developers. One of its most-used features is the AI Agent node , which wraps an LLM in a tool-calling loop: the model can call other nodes in your workflow as "tools," see the results, and decide what to do next, across multiple items and multiple rounds. To keep costs down and stay under rate limits, the Agent node supports Batch Processing — running several input items through the tool-calling loop concurrently instead of one at a time. That feature is where this bug lives. The bug: when Batch Processing is enabled with a batch size of 2 or more, and two items in the same batch both need to call a tool in the same round, one item's conversation history silently leaks into the other's from round 2 onward — or gets dropped outright. Concretely: item 0 calls get weather for Berlin, item 1 calls get stock price for ACME. By round 3, item 1's rebuilt conversation context contains item 0's Berlin weather call instead of its own stock price call. The LLM processing item 1 is now reasoning from a completely different item's history. This isn't a cosmetic bug — it's silent data/context corruption in the exact feature tool-calling memory that determines whether an AI agent gives a correct answer. And it fails silently: no exception, no error in the UI, just a wrong answer built from someone else's data. Root cause , traced through the actual engine round-trip: executeBatch.ts merges all items' outgoing requests for a round into one shared object. When it does, it keeps only the metadata which includes previousRequests — that item's own tool-call history and silently discards every other item's. buildSteps.ts , when rebuilding an item's conversation for the next round, splices whatever previousRequests survived that merge into Put together: whichever item happens to go first in the batch "wins" the shared history slot, and every other item inherits it instead of its own. I confirmed the bug with a deterministic non-flaky regression test before touching any source: mock two batch items each returning distinct tool-call data, run them through executeBatch , and assert on the merged result. It failed exactly as the root-cause analysis predicted — item 1's data was gone, item 0's leaked everywhere. The fix has three small, surgical pieces: itemIndex field to ToolCallData the type representing one tool-call step , and tag every step with it the moment it's created in buildSteps.ts . executeBatch.ts 's batch merge, instead of keeping only the first item's previousRequests , concatenate every contributing item's array. buildSteps.ts , when rebuilding an item's steps for the next round, filter the merged previousRequests down to just that item's own tagged entries before splicing them in. // buildSteps.ts — filter on the way back in if response.metadata?.previousRequests { steps.push ...response.metadata.previousRequests.filter step = step.itemIndex === itemIndex , ; } // executeBatch.ts — merge instead of discarding } else { request.actions.push.apply request.actions, batchResult.actions ; const otherPreviousRequests = batchResult.metadata?.previousRequests; if otherPreviousRequests?.length { request.metadata = { ...request.metadata, previousRequests: ... request.metadata?.previousRequests ?? , ...otherPreviousRequests , }; } } One deliberate scoping call: this same root cause also means each item's iterationCount used for the "max iterations" circuit breaker is shared across the batch rather than tracked per item. Fixing that properly would mean restructuring the whole shared-metadata shape — a bigger, riskier change. I documented it as a known follow-up instead of folding it into this PR, to keep the diff small, reviewable, and focused on the one bug with an unambiguous, demonstrable before/after. Verified: 2 new regression tests, both confirmed failing against the unfixed code and passing after the fix, plus all 338 existing tests in the Agent/agent-execution suite still green — no regressions. I wanted evidence that wasn't just "trust me, I read the code" — so I wrote a small demo harness that runs the real project code the actual executeBatch / buildSteps functions, not a toy reproduction through the exact two-item batch scenario, instrumented with Sentry. Before the fix: running the scenario produced two real Sentry warning events — "item 1's conversation history contains item 0's tool-call data" and "item 1's own tool-call history was dropped during batch merge" — plus a trace span tagged cross item leak detected: true . After the fix: same scenario, same instrumentation — zero warning events, and the trace span flips to cross item leak detected: false , with both items' data intact. Then I ran Seer Sentry's AI root-cause tool on the "before" issue. What came back was remarkable — Seer's root cause analysis independently identified the exact mechanism the unconditional splice in buildSteps , no per-item partitioning , citing the real files in my repo executeBatch.ts , buildSteps.ts lines 316–395, prepareItemContext.ts , runAgent.ts as evidence — meaning it actually read the connected repo, not just the event text. I let it go further: "Yes, make a plan" produced a 4-step plan that matches my actual fix step for step — tag ToolCallData with itemIndex , filter in buildSteps , merge in executeBatch — down to the same line numbers. Then "Yes, write a code fix" produced a diff nearly identical to what I'd already shipped. That's four independent analyses — mine, Seer's root cause, Seer's plan, and see below Gemini's — all converging on the same diagnosis and the same fix. The one genuine difference is worth calling out honestly rather than glossing over: Seer's generated filter also let untagged itemIndex === undefined entries pass through to every item as a backward-compat safety net, where mine is strict. In this codebase that difference never manifests every step is unconditionally tagged going forward , but it's a real, spottable design choice, not a rubber stamp. And the most useful gap Seer's autofix exposed: its generated diff shipped with zero tests . n8n's own contribution guidelines require tests on every PR unlabeled PRs without them auto-close after 14 days — so even a diff this close to correct wouldn't be a mergeable submission on its own. That's the concrete difference between an AI-generated patch and a human-verified fix: mine ships with regression tests confirmed failing before, passing after. I also let Seer draft its own PR from the plan — it created PR 2 on my fork https://github.com/Yuvakunaal/n8n/pull/2 , a nice piece of evidence that Sentry can go all the way from a captured error to an autonomous, code-aware pull request. My actual submission PR 34360 https://github.com/n8n-io/n8n/pull/34360 is the tested, human-verified version. I ran a three-message debugging session with Gemini, feeding it the real unfixed code and the symptom, without telling it my own conclusions first, to see if it would independently arrive at the same root cause. Prompt 1 asked it to diagnose the bug from the executeBatch.ts merge logic and buildSteps.ts splice, given the symptom item 1 inheriting item 0's history by round 3 . Gemini correctly identified the reference-hijacking merge and the unconditional splice as the mechanism. It also flagged a "secondary bug" — that request.actions.push.apply ... mutates item 0's original actions array by reference. Worth being honest about this one: it's technically true same array object , but I don't think it's actually a separate bug here — nothing else reads that reference afterward, and merging all items' actions into one array is the intended behavior anyway. Good reminder that AI analysis needs to be checked against real usage, not just taken at face value. Prompt 2 asked what the smallest fix would be, and specifically what breaks if you patch only buildSteps.ts without also fixing the merge in executeBatch.ts . Gemini got this right and explained it well: if you only filter in buildSteps , item 1's data isn't there to filter — it was already discarded two rounds earlier. Filtering a dataset that no longer exists doesn't help; you'd trade cross-contamination for outright data loss and, plausibly, an agent that repeats tool calls because it's forgotten it already made them . This matches exactly what I found empirically in the Sentry demo. Prompt 3 shared my actual shipped diff and asked Gemini to sanity-check it, including the deliberate iterationCount scoping decision. It confirmed the fix closes the conversation-history gap, and caught something I hadn't stated as sharply: because the merge spreads request.metadata item 0's and only explicitly overwrites previousRequests , any other per-item metadata field beyond previousRequests — not just iterationCount — still silently reflects only the first item's value. That's a more precise, general framing of the same follow-up I'd already scoped out, and it made its way into my PR description as a result. Across all three prompts, Gemini converged on the same diagnosis as my own analysis and Sentry's Seer — but the value wasn't in blind agreement, it was in the two places I could point at and say "that's not quite right" the array-mutation non-bug or "that's sharper than what I'd written" the general metadata-field caveat . That back-and-forth is what actually made the PR description better.