Drop-in LangGraph store that merges concurrent writes instead of silently dropping them.
Project description
agentsync
Drop-in LangGraph store that merges concurrent agent writes instead of silently dropping them.
(Above: make repro — the same two-agent graph on stock InMemoryStore vs SyncedStore. The bug is in vanilla langgraph; the fix is a one-line store swap.)
When two parallel nodes in a LangGraph graph call store.put() on the same key, the stock InMemoryStore silently overwrites the first write with the second. No error. No merge. No signal — one agent's contribution just disappears. (Verified against langgraph 1.2.6: two parallel puts produce two separate batch() calls; InMemoryStore last-write-wins clobbers the first.)
agentsync.SyncedStore is a 1-line swap that fixes this. Concurrent writes to the same key now merge — lists union, text concatenates, nested dicts deep-merge — every write is attributed to an agent, and a genuine semantic conflict (two agents setting the same scalar to different values) is escalated instead of silently resolved.
The swap
from agentsync import SyncedStore # 1
store = SyncedStore() # 2
graph = builder.compile(store=store) # 3
def my_node(state, store): # 4 langgraph injects the store
with store.acting_as("researcher"): # 5 attribute your writes
store.put(("ctx",), "notes", {"tags": [...]}) # 6
Install
pip install extodan-agentsync # when published; meanwhile:
pip install -e ".[demo]" # from a clone (or `make setup` with uv)
Requires Python 3.10+. Only runtime deps: langgraph>=1.2,<2 and loro.
Watch it work
make example # runs the /examples LangGraph swap demo
The same two-agent graph runs on InMemoryStore then SyncedStore:
BASELINE — InMemoryStore
final value: {'tags': ['benchmark'], 'status': 'published'}
-> the researcher's tags/findings/status are GONE. No error.
SWAP — SyncedStore
final value: {'tags': ['agents','benchmark','crdt'], 'status': '<escalated:status>'}
MERGEABLE WRITES (tags, findings):
synced tags : ['agents','benchmark','crdt'] <- both agents preserved
synced findings: concatenated <- both agents preserved
SEMANTIC CONFLICT (status: 'draft' vs 'published'):
baseline status: 'published' <- silently picked
synced status: ESCALATED (not auto-resolved)
contenders: researcher='draft', writer='published' <- flagged, attributed
ATTRIBUTION (per-write, survives the merge):
tags:researcher:0 -> agent=researcher tags:writer:0 -> agent=writer
API
| Method | What it does |
|---|---|
SyncedStore() |
Drop-in for InMemoryStore. Override: zero. |
store.acting_as(agent_id) |
Context manager — attribute the puts inside to an agent. |
store.on_escalation(cb) |
Register a callback fired when a semantic conflict is detected. |
store.escalations() |
List all unresolved conflicts (both contenders attributed). |
store.attribution() |
Per-key, per-write attribution map (which agent wrote each field). |
Honest scope (read this before adopting)
This is the trust pitch, not a disclaimer.
Where it wins for free. Mergeable state — concurrent edits to different fields, set unions, append logs — is handled structurally with zero model calls. This is the common case for shared agent context (notes, findings, tags), and it's where the silent-write-loss bug bites hardest.
Where it escalates, not resolves. When two agents set the same scalar to different values, that's a genuine semantic conflict with no correct merge. SyncedStore flags it with both contenders attributed and does not pick a winner. The field holds an <escalated:field> sentinel until you drain the queue.
Escalate defers both cost and correctness. The cheap thing about escalation is that it does no work — the conflicting field stays divergent. For async knowledge-merge that's free. For an agent that needs to read that field on its next step, escalate = blocked agent: the inference you "saved" reappears downstream, plus a stall. Escalate is the safer primitive (you can always bolt a resolver onto on_escalation; you can't recover a write LWW silently dropped, and can't un-spend a wrong autonomous repair). It is not a free lunch on time-to-usable-state.
What it does NOT do. No shared-codebase editing (code is un-mergeable semantic state). No standalone escalation queue/worker. No auth, multi-tenant, dashboard, or Redis/Postgres backend. Those are gated on real adoption signal.
Known limitations
Stated plainly, because hiding them is worse than having them:
- No persistence.
SyncedStoreis in-memory only — state is lost on process restart. A Redis backend is the obvious next step but is gated on a real team needing it. - Escalation blocks a reader. When a field is escalated it holds an
<escalated:field>sentinel. An agent that needs to read that field on its next step gets the sentinel, not a usable value — it is effectively blocked until someone drains the escalation. This is the cost of not silently resolving (see Honest scope); it's deliberate, not a bug. - Single-process, in-memory. No cross-process replication yet. Two LangGraph processes each holding a
SyncedStoredo not sync with each other. - Type mismatch on the same field silently drops a write. If agent A writes
{"f": ["x"]}(a list) and agent B writes{"f": "y"}(a scalar) to the same key concurrently, one write is dropped without an escalation — and which one survives depends on merge order. This is the same silent-corruption failure modeSyncedStoreprevents for same-type writes; it is a known gap across the type boundary, tracked intests/test_adversarial.py, and intended to escalate in a future version. Same-type concurrent writes (the common case) are not affected. - Field "kind" is inferred from value shape, not declared. Lists union; string values under keys named
*text/notes/findings/logconcatenate; everything else is treated as a scalar (and therefore as a conflict surface). There is no schema declaration API yet. - Attribution requires
acting_as. Writes made outside astore.acting_as(...)block are attributed to"anonymous"— attribution completeness then can't be guaranteed.
The benchmark behind it
agentsync shipped as a benchmark before it shipped as a product. make bench runs the same workload through three strategies and prints the comparison — the table that proves the bug is real and the fix is honest:
workload strategy verdict outcome writes_lost escals model_calls latency_ms
clean_merge lww FAIL corrupted 3 0 0 ~0.2
clean_merge transactional PASS auto_merged 0 0 0 ~0.2
clean_merge crdt PASS auto_merged 0 0 0 ~1.6
semantic_conflict lww FAIL corrupted 0 0 0 ~0.03
semantic_conflict transactional PASS resolved 0 1 1 ~308
semantic_conflict crdt PASS escalated 0 1 0 ~0.7
The outcome column is the honesty fix: three PASSes are not the same thing. resolved spent a model call to repair and return a usable value; escalated spent nothing but left the field divergent; corrupted silently dropped a write. Read the full adversarial breakdown in the Results section below.
How merge decisions are made
| Write type | Merge behavior |
|---|---|
| Lists | Union (order-preserving, deduped) |
Text (keys named *text/notes/findings/log) |
Concatenation |
| Nested dicts | Deep-merge |
| Scalars set to the same value | Idempotent — no conflict |
| Scalars set to different values | Escalation (both contenders attributed) |
Merge layer is Loro (eg-walker family CRDT) — deterministic, coordinator-free convergence. Every write carries an agent_id that survives every merge.
Quick start
make setup # uv sync --all-extras (provisions Python 3.12)
make example # LangGraph swap demo (the landing-page asset)
make bench # three-way benchmark harness, comparison table
make test # invariant + integration suite (15 tests)
Benchmark results
Same workload through all three strategies behind one interface.
outcome— how the strategy reached its end state. Three PASSes are not the same thing. Read it adversarially.writes_lost— the corruption signal. Nonzero on a mergeable workload = silent write drop.
On genuinely mergeable state (clean_merge), CRDT does not beat the rival. transactional and crdt are a wash: both correct, both 0 model calls, both full attribution. LWW is the only one that fails — it silently drops 3 of the mergeable writes and converges to a wrong state.
The entire differentiated value lives in one cell: semantic_conflict. There, transactional is 1 call / ~308 ms and crdt is 0 calls / ~0.7 ms. But read the outcome column: those are not the same result. transactional resolves (spends the model call, returns a field the next agent can act on). crdt escalates (flags the divergence, leaves the field divergent). Escalate is cheap precisely because it does not resolve anything — see Honest scope above for why "800× faster to flag than to resolve" is the correct framing.
The number that decides the thesis isn't in this repo: the mergeable-to-semantic conflict ratio in real agent workloads. If real shared-state contention is mostly set unions and append logs, CRDT handles the common case for free and escalates the rare exception. If it's mostly two agents writing the same field with different intent, this is an escalation router. The benchmark can't answer which regime reality is in — only a team running parallel agents can.
Status
v0.1.0 (alpha). SyncedStore verified against langgraph 1.2.6; 15 tests green; benchmark + swap demo runnable. Deliberately not built: agentsync bench --my-workload, CrewAI adapter, opt-in conflict-ratio telemetry, Redis backend — all gated on real adoption signal.
Grounding references
- eg-walker — arXiv 2409.14252;
josephg/eg-walker-reference;josephg/diamond-types. - Loro — loro.dev (the merge layer this uses).
- Rival benchmarked — CoAgent MTPO, arXiv 2606.15376.
- Boundary case (not a target) — STORM, arXiv 2605.20563.
License
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file extodan_agentsync-0.1.0.tar.gz.
File metadata
- Download URL: extodan_agentsync-0.1.0.tar.gz
- Upload date:
- Size: 174.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.15 {"installer":{"name":"uv","version":"0.11.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
78f308feb6770eb5b5eea7d9bace9437ee6f920b1c8e3acf996c237e4db5f6fb
|
|
| MD5 |
a955be08d775e35c629b50e6b7a1ccad
|
|
| BLAKE2b-256 |
c8ec5f0356ad1d609cbc02abda90310faabbb83cd12040890e6cdc3b6d3eed69
|
File details
Details for the file extodan_agentsync-0.1.0-py3-none-any.whl.
File metadata
- Download URL: extodan_agentsync-0.1.0-py3-none-any.whl
- Upload date:
- Size: 38.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.15 {"installer":{"name":"uv","version":"0.11.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0dad1becc05951bf0bbd0eca74b583e09b1c12f5927226ab5781067074f27e24
|
|
| MD5 |
efd6e3527d002f72afeb42d78c983cc2
|
|
| BLAKE2b-256 |
dd574ef46f348a5febcbdae58067dafc3bf273b5a3c99b49b11628f44e258743
|