cd /news/ai-agents/when-batching-your-ai-agents-makes-t… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-62443] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=↓ negative

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.

read7 min views1 publishedJul 16, 2026

This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.

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, 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) 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.

── more in #ai-agents 4 stories Β· sorted by recency
── more on @n8n 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/when-batching-your-a…] indexed:0 read:7min 2026-07-16 Β· β€”